Files
trumpsignal-backend/app/services/hyperliquid.py
T
k 5fb1d52026 Pre-launch hardening: KOL module, Telegram, scanners, WS resilience
Big-picture changes since b941223:

KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.

Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.

BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.

WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.

Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.

Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.

New ops scripts —
  - scripts/preflight.py: env/DB/Telegram/AI auth verification gate
  - scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
  - scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder

15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:52:56 +08:00

378 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 _get_max_leverage(self, coin: str) -> int:
"""Hyperliquid caps max leverage per asset (BTC/ETH 50×, SOL 20×,
memes typically 3-5×). Querying meta() returns each asset's
`maxLeverage`. We cache nothing — meta() is fast and HL can change
tiers. Returns a conservative 3 if lookup fails (memes default)."""
try:
meta = await self._run(self._info.meta)
for u in meta.get("universe", []):
if u.get("name") == coin:
ml = int(u.get("maxLeverage", 3))
return max(1, ml)
except Exception as exc:
logger.warning("_get_max_leverage failed for %s: %s", coin, exc)
return 3 # safe fallback for unknown / illiquid coin
async def _clip_leverage(self, coin: str, requested: int) -> int:
"""Cap user's requested leverage at the asset's HL max. Without
this, opening a 30× position on a meme (which caps at 3×) gets
rejected by HL and the trade is silently dropped."""
max_lev = await self._get_max_leverage(coin)
if requested > max_lev:
logger.info("Clipped leverage for %s: %d×%d× (HL max)",
coin, requested, max_lev)
return max_lev
return requested
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) -> int:
"""Set isolated leverage for the given coin, clipped to the asset's
HL maximum. Returns the actual leverage used (may be less than
self._leverage if asset capped). Caller should prefer this return
value when computing notional / position size."""
effective = await self._clip_leverage(coin, self._leverage)
try:
await self._run(
self._exchange.update_leverage,
effective,
coin,
False, # is_cross=False → isolated margin
)
logger.info("Set leverage %dx for %s (isolated)", effective, coin)
except Exception as exc:
logger.warning("set_leverage error (non-fatal): %s", exc)
return effective
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}
async def reduce_position(self, asset: str, fraction: float) -> dict:
"""Partially close `fraction` (0<f<1) of the CURRENT open position
for `asset` with a reduce-only IOC order. Used by System-2 staged
de-risk. Returns {"fill_price": float, "closed_fraction": float,
"already_closed": bool}.
`closed_fraction` is the fraction of the position that was actually
closed (filled_size / pre-existing size) so the caller's PnL +
remaining bookkeeping stays exact even on partial fills.
"""
coin = asset.upper()
f = max(0.0, min(1.0, float(fraction)))
if f <= 0.0:
return {"fill_price": None, "closed_fraction": 0.0, "already_closed": False}
if f >= 1.0:
r = await self.close_position(asset)
r["closed_fraction"] = 0.0 if r.get("already_closed") else 1.0
return r
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("reduce_position: no open %s on HL — already closed?", coin)
return {"fill_price": None, "closed_fraction": 0.0, "already_closed": True}
szi = float(target["szi"])
is_buy = szi < 0 # reducing a short → buy back
pre_size = abs(szi)
sz_dec = await self._get_sz_decimals(coin)
size = round(pre_size * f, sz_dec)
if size <= 0:
# Fraction rounds to nothing at this asset's size precision —
# treat as a no-op so the caller can advance the step without
# an erroneous PnL slice.
logger.warning("reduce_position: %s fraction %.3f rounds to 0 size (pre=%.6f)",
coin, f, pre_size)
return {"fill_price": None, "closed_fraction": 0.0, "already_closed": False}
mid = await self._mid_price(coin)
slippage = 0.01
raw_px = mid * (1 + slippage) if is_buy else mid * (1 - slippage)
if raw_px >= 10000:
limit_px = float(round(raw_px))
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("Reducing %s by %.0f%%: size=%.6f @ limit %.2f (pre=%.6f)",
coin, f * 100, size, limit_px, pre_size)
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 {}
if "error" in status:
raise ValueError(f"HL reduce rejected: {status['error']}")
filled_info = status.get("filled") or {}
total_sz = float(filled_info.get("totalSz", 0) or 0)
if total_sz <= 0:
raise ValueError(f"reduce_position {coin}: IOC returned 0 fill")
fill_price = float(filled_info.get("avgPx", mid) or mid)
closed_fraction = (total_sz / pre_size) if pre_size > 0 else 0.0
logger.info("Reduced %s: closed %.6f / %.6f (%.1f%%) @ %.2f",
coin, total_sz, pre_size, closed_fraction * 100, fill_price)
return {"fill_price": fill_price, "closed_fraction": closed_fraction,
"already_closed": False}