feat(V2): migrazione hyperliquid completa
- exchanges/hyperliquid/{client,leverage_cap,tools}.py
- routers/hyperliquid.py con 16 endpoint /mcp-hyperliquid/tools/*
- builder hyperliquid in exchanges/__init__.py
- test migrati: test_client, test_leverage_cap (skip V1: server_acl, environment_info)
- test builder hyperliquid (testnet vs mainnet base_url)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,4 +27,13 @@ async def build_client(
|
|||||||
api_secret=settings.bybit.api_secret.get_secret_value(),
|
api_secret=settings.bybit.api_secret.get_secret_value(),
|
||||||
testnet=(env == "testnet"),
|
testnet=(env == "testnet"),
|
||||||
)
|
)
|
||||||
|
if exchange == "hyperliquid":
|
||||||
|
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
|
||||||
|
|
||||||
|
return HyperliquidClient(
|
||||||
|
wallet_address=settings.hyperliquid.wallet_address,
|
||||||
|
private_key=settings.hyperliquid.private_key.get_secret_value(),
|
||||||
|
testnet=(env == "testnet"),
|
||||||
|
api_wallet_address=settings.hyperliquid.api_wallet_address,
|
||||||
|
)
|
||||||
raise ValueError(f"unsupported exchange: {exchange}")
|
raise ValueError(f"unsupported exchange: {exchange}")
|
||||||
|
|||||||
@@ -0,0 +1,577 @@
|
|||||||
|
"""Hyperliquid REST API client for perpetual futures trading."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime as _dt
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cerbero_mcp.common import indicators as ind
|
||||||
|
from cerbero_mcp.common.http import async_client
|
||||||
|
|
||||||
|
BASE_LIVE = "https://api.hyperliquid.xyz"
|
||||||
|
BASE_TESTNET = "https://api.hyperliquid-testnet.xyz"
|
||||||
|
|
||||||
|
RESOLUTION_MAP = {
|
||||||
|
"1m": "1m",
|
||||||
|
"5m": "5m",
|
||||||
|
"15m": "15m",
|
||||||
|
"1h": "1h",
|
||||||
|
"4h": "4h",
|
||||||
|
"1d": "1d",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from eth_account import Account
|
||||||
|
from hyperliquid.exchange import Exchange
|
||||||
|
from hyperliquid.utils import constants as hl_constants
|
||||||
|
|
||||||
|
_SDK_AVAILABLE = True
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
_SDK_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
def _to_ms(date_str: str) -> int:
|
||||||
|
try:
|
||||||
|
dt = _dt.datetime.fromisoformat(date_str)
|
||||||
|
except ValueError:
|
||||||
|
dt = _dt.datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
|
return int(dt.timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
class HyperliquidClient:
|
||||||
|
"""Async client for the Hyperliquid API.
|
||||||
|
|
||||||
|
Read operations use direct HTTP calls via httpx against /info.
|
||||||
|
Write operations delegate to hyperliquid-python-sdk for EIP-712 signing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
wallet_address: str,
|
||||||
|
private_key: str,
|
||||||
|
testnet: bool = True,
|
||||||
|
api_wallet_address: str | None = None,
|
||||||
|
):
|
||||||
|
self.wallet_address = wallet_address
|
||||||
|
self.private_key = private_key
|
||||||
|
self.testnet = testnet
|
||||||
|
self.api_wallet_address = api_wallet_address or wallet_address
|
||||||
|
self.base_url = BASE_TESTNET if testnet else BASE_LIVE
|
||||||
|
self._exchange: Any | None = None
|
||||||
|
|
||||||
|
# ── SDK exchange (lazy) ────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_exchange(self) -> Any:
|
||||||
|
"""Return (and cache) an SDK Exchange instance for write ops."""
|
||||||
|
if not _SDK_AVAILABLE:
|
||||||
|
raise RuntimeError(
|
||||||
|
"hyperliquid-python-sdk is not installed; write operations unavailable."
|
||||||
|
)
|
||||||
|
if self._exchange is None:
|
||||||
|
account = Account.from_key(self.private_key)
|
||||||
|
base_url = (
|
||||||
|
hl_constants.TESTNET_API_URL if self.testnet else hl_constants.MAINNET_API_URL
|
||||||
|
)
|
||||||
|
empty_spot_meta: dict[str, Any] = {"universe": [], "tokens": []}
|
||||||
|
self._exchange = Exchange(
|
||||||
|
account,
|
||||||
|
base_url,
|
||||||
|
account_address=self.wallet_address,
|
||||||
|
spot_meta=empty_spot_meta,
|
||||||
|
)
|
||||||
|
return self._exchange
|
||||||
|
|
||||||
|
# ── Internal helpers ───────────────────────────────────────
|
||||||
|
|
||||||
|
async def _post(self, payload: dict[str, Any]) -> Any:
|
||||||
|
"""POST JSON to the /info endpoint."""
|
||||||
|
async with async_client(timeout=15.0) as http:
|
||||||
|
resp = await http.post(f"{self.base_url}/info", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _run_sync(func: Any, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
"""Run a synchronous SDK call in the default executor."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||||
|
|
||||||
|
# ── Read tools ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_markets(self) -> list[dict[str, Any]]:
|
||||||
|
"""List all perp markets with metadata and current stats."""
|
||||||
|
data = await self._post({"type": "metaAndAssetCtxs"})
|
||||||
|
universe = data[0]["universe"]
|
||||||
|
ctx_list = data[1]
|
||||||
|
markets = []
|
||||||
|
for meta, ctx in zip(universe, ctx_list, strict=True):
|
||||||
|
markets.append(
|
||||||
|
{
|
||||||
|
"asset": meta["name"],
|
||||||
|
"mark_price": float(ctx.get("markPx", 0)),
|
||||||
|
"funding_rate": float(ctx.get("funding", 0)),
|
||||||
|
"open_interest": float(ctx.get("openInterest", 0)),
|
||||||
|
"volume_24h": float(ctx.get("dayNtlVlm", 0)),
|
||||||
|
"max_leverage": int(meta.get("maxLeverage", 1)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return markets
|
||||||
|
|
||||||
|
async def get_ticker(self, instrument: str) -> dict[str, Any]:
|
||||||
|
"""Get ticker information for a specific asset."""
|
||||||
|
markets = await self.get_markets()
|
||||||
|
for m in markets:
|
||||||
|
if m["asset"].upper() == instrument.upper():
|
||||||
|
return {
|
||||||
|
"asset": m["asset"],
|
||||||
|
"mark_price": m["mark_price"],
|
||||||
|
"mid_price": m["mark_price"],
|
||||||
|
"funding_rate": m["funding_rate"],
|
||||||
|
"open_interest": m["open_interest"],
|
||||||
|
"volume_24h": m["volume_24h"],
|
||||||
|
"premium": 0.0,
|
||||||
|
}
|
||||||
|
return {"error": f"Asset {instrument} not found"}
|
||||||
|
|
||||||
|
async def get_orderbook(self, instrument: str, depth: int = 10) -> dict[str, Any]:
|
||||||
|
"""Get L2 order book for an asset."""
|
||||||
|
data = await self._post({"type": "l2Book", "coin": instrument.upper()})
|
||||||
|
levels = data.get("levels", [[], []])
|
||||||
|
bids = [{"price": float(b["px"]), "size": float(b["sz"])} for b in levels[0][:depth]]
|
||||||
|
asks = [{"price": float(a["px"]), "size": float(a["sz"])} for a in levels[1][:depth]]
|
||||||
|
return {"asset": instrument, "bids": bids, "asks": asks}
|
||||||
|
|
||||||
|
async def get_positions(self) -> list[dict[str, Any]]:
|
||||||
|
"""Get open positions for the wallet."""
|
||||||
|
data = await self._post(
|
||||||
|
{"type": "clearinghouseState", "user": self.wallet_address}
|
||||||
|
)
|
||||||
|
positions = []
|
||||||
|
for ap in data.get("assetPositions", []):
|
||||||
|
pos = ap.get("position", {})
|
||||||
|
size = float(pos.get("szi", 0))
|
||||||
|
if size == 0:
|
||||||
|
continue
|
||||||
|
leverage_data = pos.get("leverage", {})
|
||||||
|
lev_value = (
|
||||||
|
leverage_data.get("value", "1")
|
||||||
|
if isinstance(leverage_data, dict)
|
||||||
|
else str(leverage_data)
|
||||||
|
)
|
||||||
|
positions.append(
|
||||||
|
{
|
||||||
|
"asset": pos.get("coin", ""),
|
||||||
|
"size": abs(size),
|
||||||
|
"direction": "long" if size > 0 else "short",
|
||||||
|
"entry_price": float(pos.get("entryPx", 0) or 0),
|
||||||
|
"unrealized_pnl": float(pos.get("unrealizedPnl", 0)),
|
||||||
|
"leverage": float(lev_value),
|
||||||
|
"liquidation_price": float(pos.get("liquidationPx", 0) or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return positions
|
||||||
|
|
||||||
|
async def get_account_summary(self) -> dict[str, Any]:
|
||||||
|
"""Get account summary (equity, balance, margin) including spot balances.
|
||||||
|
|
||||||
|
Con Unified Account, spot USDC e perps condividono collaterale.
|
||||||
|
`spot_fetch_ok` / `perps_fetch_ok` indicano se i due lati sono stati
|
||||||
|
letti correttamente: se uno dei due è False il chiamante dovrebbe
|
||||||
|
considerare `equity`/`available_balance` un lower bound.
|
||||||
|
"""
|
||||||
|
perps_fetch_ok = True
|
||||||
|
perps_equity = 0.0
|
||||||
|
perps_available = 0.0
|
||||||
|
margin_used = 0.0
|
||||||
|
unrealized_pnl = 0.0
|
||||||
|
try:
|
||||||
|
data = await self._post(
|
||||||
|
{"type": "clearinghouseState", "user": self.wallet_address}
|
||||||
|
)
|
||||||
|
margin = data.get("marginSummary") or {}
|
||||||
|
perps_equity = float(margin.get("accountValue", 0) or 0)
|
||||||
|
perps_available = float(margin.get("totalRawUsd", 0) or 0)
|
||||||
|
margin_used = float(margin.get("totalMarginUsed", 0) or 0)
|
||||||
|
unrealized_pnl = float(margin.get("totalNtlPos", 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
perps_fetch_ok = False
|
||||||
|
|
||||||
|
spot_fetch_ok = True
|
||||||
|
spot_usdc = 0.0
|
||||||
|
try:
|
||||||
|
spot_data = await self._post(
|
||||||
|
{"type": "spotClearinghouseState", "user": self.wallet_address}
|
||||||
|
)
|
||||||
|
for b in spot_data.get("balances", []) or []:
|
||||||
|
if b.get("coin") == "USDC":
|
||||||
|
spot_usdc = float(b.get("total", 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
spot_fetch_ok = False
|
||||||
|
|
||||||
|
total_equity = perps_equity + spot_usdc
|
||||||
|
total_available = perps_available + spot_usdc
|
||||||
|
return {
|
||||||
|
"equity": total_equity,
|
||||||
|
"perps_equity": perps_equity,
|
||||||
|
"perps_available": perps_available,
|
||||||
|
"spot_usdc": spot_usdc,
|
||||||
|
"available_balance": total_available,
|
||||||
|
"margin_used": margin_used,
|
||||||
|
"unrealized_pnl": unrealized_pnl,
|
||||||
|
"perps_fetch_ok": perps_fetch_ok,
|
||||||
|
"spot_fetch_ok": spot_fetch_ok,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_trade_history(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
"""Get recent trade fills."""
|
||||||
|
data = await self._post({"type": "userFills", "user": self.wallet_address})
|
||||||
|
trades = []
|
||||||
|
for t in data[:limit]:
|
||||||
|
trades.append(
|
||||||
|
{
|
||||||
|
"asset": t.get("coin", ""),
|
||||||
|
"side": t.get("side", ""),
|
||||||
|
"size": float(t.get("sz", 0)),
|
||||||
|
"price": float(t.get("px", 0)),
|
||||||
|
"fee": float(t.get("fee", 0)),
|
||||||
|
"timestamp": t.get("time", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return trades
|
||||||
|
|
||||||
|
async def get_historical(
|
||||||
|
self, instrument: str, start_date: str, end_date: str, resolution: str = "1h"
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get OHLCV candles for an asset."""
|
||||||
|
start_ms = _to_ms(start_date)
|
||||||
|
end_ms = _to_ms(end_date)
|
||||||
|
interval = RESOLUTION_MAP.get(resolution, resolution)
|
||||||
|
data = await self._post(
|
||||||
|
{
|
||||||
|
"type": "candleSnapshot",
|
||||||
|
"req": {
|
||||||
|
"coin": instrument.upper(),
|
||||||
|
"interval": interval,
|
||||||
|
"startTime": start_ms,
|
||||||
|
"endTime": end_ms,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
candles = []
|
||||||
|
for c in data:
|
||||||
|
candles.append(
|
||||||
|
{
|
||||||
|
"timestamp": c.get("t", 0),
|
||||||
|
"open": float(c.get("o", 0)),
|
||||||
|
"high": float(c.get("h", 0)),
|
||||||
|
"low": float(c.get("l", 0)),
|
||||||
|
"close": float(c.get("c", 0)),
|
||||||
|
"volume": float(c.get("v", 0)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"candles": candles}
|
||||||
|
|
||||||
|
async def get_open_orders(self) -> list[dict[str, Any]]:
|
||||||
|
"""Get all open orders for the wallet."""
|
||||||
|
data = await self._post({"type": "openOrders", "user": self.wallet_address})
|
||||||
|
orders = []
|
||||||
|
for o in data:
|
||||||
|
orders.append(
|
||||||
|
{
|
||||||
|
"oid": o.get("oid"),
|
||||||
|
"asset": o.get("coin", ""),
|
||||||
|
"side": o.get("side", ""),
|
||||||
|
"size": float(o.get("sz", 0)),
|
||||||
|
"price": float(o.get("limitPx", 0)),
|
||||||
|
"order_type": o.get("orderType", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return orders
|
||||||
|
|
||||||
|
async def basis_spot_perp(self, asset: str) -> dict[str, Any]:
|
||||||
|
asset = asset.upper()
|
||||||
|
# Spot reference price from Coinbase (mainnet reference, anche se HL è testnet)
|
||||||
|
spot_price: float | None = None
|
||||||
|
spot_source = "coinbase"
|
||||||
|
try:
|
||||||
|
async with async_client(timeout=8.0) as c:
|
||||||
|
resp = await c.get(f"https://api.coinbase.com/v2/prices/{asset}-USD/spot")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
spot_price = float(resp.json().get("data", {}).get("amount"))
|
||||||
|
except Exception:
|
||||||
|
spot_price = None
|
||||||
|
if spot_price is None:
|
||||||
|
try:
|
||||||
|
async with async_client(timeout=8.0) as c:
|
||||||
|
resp = await c.get(
|
||||||
|
"https://api.kraken.com/0/public/Ticker", params={"pair": f"{asset}USD"}
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
res = resp.json().get("result") or {}
|
||||||
|
first = next(iter(res.values()), {})
|
||||||
|
price = (first.get("c") or [None])[0]
|
||||||
|
spot_price = float(price) if price else None
|
||||||
|
spot_source = "kraken"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Perp price + funding from HL
|
||||||
|
try:
|
||||||
|
ctx = await self._post({"type": "metaAndAssetCtxs"})
|
||||||
|
universe = ctx[0]["universe"]
|
||||||
|
ctx_list = ctx[1]
|
||||||
|
perp_price = None
|
||||||
|
funding = None
|
||||||
|
for meta, c in zip(universe, ctx_list, strict=True):
|
||||||
|
if meta["name"].upper() == asset:
|
||||||
|
perp_price = float(c.get("markPx", 0))
|
||||||
|
funding = float(c.get("funding", 0))
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
perp_price = None
|
||||||
|
funding = None
|
||||||
|
|
||||||
|
if spot_price is None or perp_price is None:
|
||||||
|
return {
|
||||||
|
"asset": asset,
|
||||||
|
"spot_price": spot_price,
|
||||||
|
"perp_price": perp_price,
|
||||||
|
"error": "missing spot or perp price",
|
||||||
|
}
|
||||||
|
|
||||||
|
basis_abs = perp_price - spot_price
|
||||||
|
basis_pct = round(basis_abs / spot_price * 100, 4)
|
||||||
|
basis_ann_funding = (
|
||||||
|
round(funding * 24 * 365 * 100, 2) if funding is not None else None
|
||||||
|
)
|
||||||
|
carry_opp = bool(
|
||||||
|
basis_ann_funding is not None
|
||||||
|
and basis_ann_funding > 5
|
||||||
|
and (funding or 0) > 0.0001
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"asset": asset,
|
||||||
|
"spot_price": spot_price,
|
||||||
|
"spot_source": spot_source,
|
||||||
|
"perp_price": perp_price,
|
||||||
|
"basis_absolute": round(basis_abs, 4),
|
||||||
|
"basis_pct": basis_pct,
|
||||||
|
"current_funding_hourly": funding,
|
||||||
|
"basis_annualized_funding_only": basis_ann_funding,
|
||||||
|
"carry_opportunity": carry_opp,
|
||||||
|
"testnet": self.testnet,
|
||||||
|
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_funding_rate(self, instrument: str) -> dict[str, Any]:
|
||||||
|
"""Get current and recent historical funding rates for an asset."""
|
||||||
|
data = await self._post({"type": "metaAndAssetCtxs"})
|
||||||
|
universe = data[0]["universe"]
|
||||||
|
ctx_list = data[1]
|
||||||
|
current_rate = None
|
||||||
|
for meta, ctx in zip(universe, ctx_list, strict=True):
|
||||||
|
if meta["name"].upper() == instrument.upper():
|
||||||
|
current_rate = float(ctx.get("funding", 0))
|
||||||
|
break
|
||||||
|
if current_rate is None:
|
||||||
|
return {"error": f"Asset {instrument} not found"}
|
||||||
|
|
||||||
|
# Fetch funding history (last 7 days)
|
||||||
|
end_ms = int(_dt.datetime.utcnow().timestamp() * 1000)
|
||||||
|
start_ms = end_ms - 7 * 24 * 3600 * 1000
|
||||||
|
history_data = await self._post(
|
||||||
|
{
|
||||||
|
"type": "fundingHistory",
|
||||||
|
"coin": instrument.upper(),
|
||||||
|
"startTime": start_ms,
|
||||||
|
"endTime": end_ms,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
history = []
|
||||||
|
for entry in history_data:
|
||||||
|
history.append(
|
||||||
|
{
|
||||||
|
"timestamp": entry.get("time", 0),
|
||||||
|
"funding_rate": float(entry.get("fundingRate", 0)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"asset": instrument,
|
||||||
|
"current_funding_rate": current_rate,
|
||||||
|
"history": history,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_indicators(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
indicators: list[str],
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
resolution: str = "1h",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compute technical indicators over OHLCV data."""
|
||||||
|
historical = await self.get_historical(instrument, start_date, end_date, resolution)
|
||||||
|
candles = historical.get("candles", [])
|
||||||
|
closes = [c["close"] for c in candles]
|
||||||
|
highs = [c["high"] for c in candles]
|
||||||
|
lows = [c["low"] for c in candles]
|
||||||
|
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for indicator in indicators:
|
||||||
|
name = indicator.lower()
|
||||||
|
if name == "sma":
|
||||||
|
result["sma"] = ind.sma(closes, 20)
|
||||||
|
elif name == "rsi":
|
||||||
|
result["rsi"] = ind.rsi(closes)
|
||||||
|
elif name == "atr":
|
||||||
|
result["atr"] = ind.atr(highs, lows, closes)
|
||||||
|
elif name == "macd":
|
||||||
|
result["macd"] = ind.macd(closes)
|
||||||
|
elif name == "adx":
|
||||||
|
result["adx"] = ind.adx(highs, lows, closes)
|
||||||
|
else:
|
||||||
|
result[name] = None
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Write tools (via SDK) ──────────────────────────────────
|
||||||
|
|
||||||
|
async def place_order(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
side: str,
|
||||||
|
amount: float,
|
||||||
|
type: str = "limit",
|
||||||
|
price: float | None = None,
|
||||||
|
reduce_only: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Place an order on Hyperliquid using the SDK for EIP-712 signing."""
|
||||||
|
exchange = self._get_exchange()
|
||||||
|
is_buy = side.lower() in ("buy", "long")
|
||||||
|
coin = instrument.upper()
|
||||||
|
|
||||||
|
if type == "market":
|
||||||
|
ot: dict[str, Any] = {"limit": {"tif": "Ioc"}}
|
||||||
|
if price is None:
|
||||||
|
ticker = await self.get_ticker(coin)
|
||||||
|
mark = ticker.get("mark_price", 0)
|
||||||
|
price = round(mark * 1.03, 1) if is_buy else round(mark * 0.97, 1)
|
||||||
|
elif type in ("stop_market", "stop_loss"):
|
||||||
|
ot = {"trigger": {"triggerPx": float(price), "isMarket": True, "tpsl": "sl"}}
|
||||||
|
elif type == "take_profit":
|
||||||
|
ot = {"trigger": {"triggerPx": float(price), "isMarket": True, "tpsl": "tp"}}
|
||||||
|
else:
|
||||||
|
ot = {"limit": {"tif": "Gtc"}}
|
||||||
|
|
||||||
|
if price is None:
|
||||||
|
return {"error": "price is required for limit orders"}
|
||||||
|
|
||||||
|
result = await self._run_sync(
|
||||||
|
exchange.order, coin, is_buy, amount, price, ot, reduce_only
|
||||||
|
)
|
||||||
|
|
||||||
|
status = result.get("status", "unknown")
|
||||||
|
response = result.get("response", {})
|
||||||
|
if isinstance(response, str):
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"error": response,
|
||||||
|
"order_id": "",
|
||||||
|
"filled_size": 0,
|
||||||
|
"avg_fill_price": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses = response.get("data", {}).get("statuses", [{}])
|
||||||
|
first = statuses[0] if statuses else {}
|
||||||
|
if isinstance(first, str):
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"error": first,
|
||||||
|
"order_id": "",
|
||||||
|
"filled_size": 0,
|
||||||
|
"avg_fill_price": 0,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"order_id": first.get("resting", {}).get(
|
||||||
|
"oid", first.get("filled", {}).get("oid", "")
|
||||||
|
),
|
||||||
|
"status": status,
|
||||||
|
"filled_size": float(first.get("filled", {}).get("totalSz", 0)),
|
||||||
|
"avg_fill_price": float(first.get("filled", {}).get("avgPx", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def cancel_order(self, order_id: str, instrument: str) -> dict[str, Any]:
|
||||||
|
"""Cancel an existing order using the SDK."""
|
||||||
|
exchange = self._get_exchange()
|
||||||
|
result = await self._run_sync(
|
||||||
|
exchange.cancel, instrument.upper(), int(order_id)
|
||||||
|
)
|
||||||
|
status = result.get("status", "unknown")
|
||||||
|
response = result.get("response", "")
|
||||||
|
if isinstance(response, str) and status == "err":
|
||||||
|
return {"order_id": order_id, "status": status, "error": response}
|
||||||
|
return {"order_id": order_id, "status": status}
|
||||||
|
|
||||||
|
async def set_stop_loss(
|
||||||
|
self, instrument: str, stop_price: float, size: float
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Set a stop-loss trigger order."""
|
||||||
|
# Determine direction by checking open position
|
||||||
|
positions = await self.get_positions()
|
||||||
|
direction = "sell" # default: assume long
|
||||||
|
for pos in positions:
|
||||||
|
if pos["asset"].upper() == instrument.upper():
|
||||||
|
direction = "sell" if pos["direction"] == "long" else "buy"
|
||||||
|
if size == 0:
|
||||||
|
size = pos["size"]
|
||||||
|
break
|
||||||
|
return await self.place_order(
|
||||||
|
instrument=instrument,
|
||||||
|
side=direction,
|
||||||
|
amount=size,
|
||||||
|
type="stop_loss",
|
||||||
|
price=stop_price,
|
||||||
|
reduce_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_take_profit(
|
||||||
|
self, instrument: str, tp_price: float, size: float
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Set a take-profit trigger order."""
|
||||||
|
positions = await self.get_positions()
|
||||||
|
direction = "sell" # default: assume long
|
||||||
|
for pos in positions:
|
||||||
|
if pos["asset"].upper() == instrument.upper():
|
||||||
|
direction = "sell" if pos["direction"] == "long" else "buy"
|
||||||
|
if size == 0:
|
||||||
|
size = pos["size"]
|
||||||
|
break
|
||||||
|
return await self.place_order(
|
||||||
|
instrument=instrument,
|
||||||
|
side=direction,
|
||||||
|
amount=size,
|
||||||
|
type="take_profit",
|
||||||
|
price=tp_price,
|
||||||
|
reduce_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close_position(self, instrument: str) -> dict[str, Any]:
|
||||||
|
"""Close an open position for the given asset using market_close."""
|
||||||
|
exchange = self._get_exchange()
|
||||||
|
try:
|
||||||
|
result = await self._run_sync(exchange.market_close, instrument.upper())
|
||||||
|
return {
|
||||||
|
"status": result.get("status", "unknown"),
|
||||||
|
"asset": instrument,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"error": str(exc), "asset": instrument}
|
||||||
|
|
||||||
|
async def health(self) -> dict[str, Any]:
|
||||||
|
"""Health check — ping /info for server status."""
|
||||||
|
try:
|
||||||
|
await self._post({"type": "meta"})
|
||||||
|
return {"status": "ok", "testnet": self.testnet}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "error", "error": str(exc)}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Leverage cap server-side per place_order.
|
||||||
|
|
||||||
|
Cap letto dal secret JSON via campo `max_leverage`. Default 1 (cash) se assente.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def get_max_leverage(creds: dict) -> int:
|
||||||
|
"""Legge max_leverage dal secret. Default 1 se mancante."""
|
||||||
|
raw = creds.get("max_leverage", 1)
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
value = 1
|
||||||
|
return max(1, value)
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_leverage(
|
||||||
|
requested: int | float | None,
|
||||||
|
*,
|
||||||
|
creds: dict,
|
||||||
|
exchange: str,
|
||||||
|
) -> int:
|
||||||
|
"""Verifica e applica leverage cap. Ritorna leverage applicabile.
|
||||||
|
|
||||||
|
Solleva HTTPException(403, LEVERAGE_CAP_EXCEEDED) se requested > cap.
|
||||||
|
Se requested is None, applica il cap come default.
|
||||||
|
"""
|
||||||
|
cap = get_max_leverage(creds)
|
||||||
|
if requested is None:
|
||||||
|
return cap
|
||||||
|
lev = int(requested)
|
||||||
|
if lev < 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail={
|
||||||
|
"error": "LEVERAGE_CAP_EXCEEDED",
|
||||||
|
"exchange": exchange,
|
||||||
|
"requested": lev,
|
||||||
|
"max": cap,
|
||||||
|
"reason": "leverage must be >= 1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if lev > cap:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail={
|
||||||
|
"error": "LEVERAGE_CAP_EXCEEDED",
|
||||||
|
"exchange": exchange,
|
||||||
|
"requested": lev,
|
||||||
|
"max": cap,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return lev
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""Tool hyperliquid V2: pydantic schemas + async functions.
|
||||||
|
|
||||||
|
Ogni funzione prende (client: HyperliquidClient, params: <Req>) e restituisce
|
||||||
|
un dict (o un model Pydantic). Pure logica, no FastAPI dependency, no ACL.
|
||||||
|
L'autenticazione bearer è gestita dal middleware in cerbero_mcp.auth;
|
||||||
|
l'audit verrà cablato dal router via request.state.environment.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator, model_validator
|
||||||
|
|
||||||
|
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
|
||||||
|
from cerbero_mcp.exchanges.hyperliquid.leverage_cap import (
|
||||||
|
enforce_leverage as _enforce_leverage,
|
||||||
|
)
|
||||||
|
from cerbero_mcp.exchanges.hyperliquid.leverage_cap import get_max_leverage
|
||||||
|
|
||||||
|
# === Schemas: reads ===
|
||||||
|
|
||||||
|
|
||||||
|
class GetMarketsReq(BaseModel):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GetTickerReq(BaseModel):
|
||||||
|
instrument: str
|
||||||
|
|
||||||
|
|
||||||
|
class GetOrderbookReq(BaseModel):
|
||||||
|
instrument: str
|
||||||
|
depth: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
class GetPositionsReq(BaseModel):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GetAccountSummaryReq(BaseModel):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GetTradeHistoryReq(BaseModel):
|
||||||
|
limit: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
class GetHistoricalReq(BaseModel):
|
||||||
|
instrument: str | None = None
|
||||||
|
asset: str | None = None
|
||||||
|
start_date: str | None = None
|
||||||
|
end_date: str | None = None
|
||||||
|
resolution: str = "1h"
|
||||||
|
interval: str | None = None
|
||||||
|
limit: int = 50
|
||||||
|
|
||||||
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _normalize(self):
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
sym = self.instrument or self.asset
|
||||||
|
if not sym:
|
||||||
|
raise ValueError("instrument (or asset) is required")
|
||||||
|
self.instrument = sym
|
||||||
|
if self.interval:
|
||||||
|
self.resolution = self.interval
|
||||||
|
if not self.end_date:
|
||||||
|
self.end_date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
if not self.start_date:
|
||||||
|
days = max(1, self.limit // 6)
|
||||||
|
self.start_date = (
|
||||||
|
datetime.now(UTC) - timedelta(days=days)
|
||||||
|
).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class GetOpenOrdersReq(BaseModel):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GetFundingRateReq(BaseModel):
|
||||||
|
instrument: str
|
||||||
|
|
||||||
|
|
||||||
|
class BasisSpotPerpReq(BaseModel):
|
||||||
|
asset: str
|
||||||
|
|
||||||
|
|
||||||
|
class GetIndicatorsReq(BaseModel):
|
||||||
|
instrument: str | None = None
|
||||||
|
asset: str | None = None
|
||||||
|
indicators: list[str] = ["rsi", "atr", "macd", "adx"]
|
||||||
|
start_date: str | None = None
|
||||||
|
end_date: str | None = None
|
||||||
|
resolution: str = "1h"
|
||||||
|
interval: str | None = None
|
||||||
|
limit: int = 50
|
||||||
|
|
||||||
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _normalize(self):
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
sym = self.instrument or self.asset
|
||||||
|
if not sym:
|
||||||
|
raise ValueError("instrument (or asset) is required")
|
||||||
|
self.instrument = sym
|
||||||
|
if self.interval:
|
||||||
|
self.resolution = self.interval
|
||||||
|
if not self.end_date:
|
||||||
|
self.end_date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
if not self.start_date:
|
||||||
|
days = max(2, self.limit // 6)
|
||||||
|
self.start_date = (
|
||||||
|
datetime.now(UTC) - timedelta(days=days)
|
||||||
|
).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
return self
|
||||||
|
|
||||||
|
@field_validator("indicators", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce_indicators(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
import json
|
||||||
|
s = v.strip()
|
||||||
|
if s.startswith("["):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(s)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [str(x).strip() for x in parsed if str(x).strip()]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return [x.strip() for x in s.split(",") if x.strip()]
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
raise ValueError(
|
||||||
|
"indicators must be a list like ['rsi','atr','macd'] "
|
||||||
|
"or a comma-separated string like 'rsi,atr,macd'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Schemas: writes ===
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceOrderReq(BaseModel):
|
||||||
|
instrument: str
|
||||||
|
side: str # "buy" | "sell"
|
||||||
|
amount: float
|
||||||
|
type: str = "limit"
|
||||||
|
price: float | None = None
|
||||||
|
reduce_only: bool = False
|
||||||
|
leverage: int | None = None # CER-016: None → default cap (3x)
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"summary": "Market buy 0.01 BTC perp",
|
||||||
|
"value": {
|
||||||
|
"instrument": "BTC",
|
||||||
|
"side": "buy",
|
||||||
|
"amount": 0.01,
|
||||||
|
"type": "market",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CancelOrderReq(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
instrument: str
|
||||||
|
|
||||||
|
|
||||||
|
class SetStopLossReq(BaseModel):
|
||||||
|
instrument: str
|
||||||
|
stop_price: float
|
||||||
|
size: float
|
||||||
|
|
||||||
|
|
||||||
|
class SetTakeProfitReq(BaseModel):
|
||||||
|
instrument: str
|
||||||
|
tp_price: float
|
||||||
|
size: float
|
||||||
|
|
||||||
|
|
||||||
|
class ClosePositionReq(BaseModel):
|
||||||
|
instrument: str
|
||||||
|
|
||||||
|
|
||||||
|
# === Tools (reads) ===
|
||||||
|
|
||||||
|
|
||||||
|
async def environment_info(
|
||||||
|
client: HyperliquidClient, *, creds: dict, env_info: Any | None = None
|
||||||
|
) -> dict:
|
||||||
|
if env_info is None:
|
||||||
|
return {
|
||||||
|
"exchange": "hyperliquid",
|
||||||
|
"environment": "testnet" if getattr(client, "testnet", True) else "mainnet",
|
||||||
|
"source": "credentials",
|
||||||
|
"env_value": None,
|
||||||
|
"base_url": getattr(client, "base_url", None),
|
||||||
|
"max_leverage": get_max_leverage(creds),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"exchange": env_info.exchange,
|
||||||
|
"environment": env_info.environment,
|
||||||
|
"source": env_info.source,
|
||||||
|
"env_value": env_info.env_value,
|
||||||
|
"base_url": env_info.base_url,
|
||||||
|
"max_leverage": get_max_leverage(creds),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_markets(client: HyperliquidClient, params: GetMarketsReq) -> list[dict]:
|
||||||
|
return await client.get_markets()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ticker(client: HyperliquidClient, params: GetTickerReq) -> dict:
|
||||||
|
return await client.get_ticker(params.instrument)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_orderbook(client: HyperliquidClient, params: GetOrderbookReq) -> dict:
|
||||||
|
return await client.get_orderbook(params.instrument, params.depth)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_positions(
|
||||||
|
client: HyperliquidClient, params: GetPositionsReq
|
||||||
|
) -> list[dict]:
|
||||||
|
return await client.get_positions()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_account_summary(
|
||||||
|
client: HyperliquidClient, params: GetAccountSummaryReq
|
||||||
|
) -> dict:
|
||||||
|
return await client.get_account_summary()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_trade_history(
|
||||||
|
client: HyperliquidClient, params: GetTradeHistoryReq
|
||||||
|
) -> list[dict]:
|
||||||
|
return await client.get_trade_history(params.limit)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_historical(
|
||||||
|
client: HyperliquidClient, params: GetHistoricalReq
|
||||||
|
) -> dict:
|
||||||
|
return await client.get_historical(
|
||||||
|
params.instrument, params.start_date, params.end_date, params.resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_open_orders(
|
||||||
|
client: HyperliquidClient, params: GetOpenOrdersReq
|
||||||
|
) -> list[dict]:
|
||||||
|
return await client.get_open_orders()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_funding_rate(
|
||||||
|
client: HyperliquidClient, params: GetFundingRateReq
|
||||||
|
) -> dict:
|
||||||
|
return await client.get_funding_rate(params.instrument)
|
||||||
|
|
||||||
|
|
||||||
|
async def basis_spot_perp(
|
||||||
|
client: HyperliquidClient, params: BasisSpotPerpReq
|
||||||
|
) -> dict:
|
||||||
|
return await client.basis_spot_perp(params.asset)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_indicators(
|
||||||
|
client: HyperliquidClient, params: GetIndicatorsReq
|
||||||
|
) -> dict:
|
||||||
|
return await client.get_indicators(
|
||||||
|
params.instrument,
|
||||||
|
params.indicators,
|
||||||
|
params.start_date,
|
||||||
|
params.end_date,
|
||||||
|
params.resolution,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Tools (writes) ===
|
||||||
|
|
||||||
|
|
||||||
|
async def place_order(
|
||||||
|
client: HyperliquidClient, params: PlaceOrderReq, *, creds: dict
|
||||||
|
) -> dict:
|
||||||
|
_enforce_leverage(params.leverage, creds=creds, exchange="hyperliquid")
|
||||||
|
result = await client.place_order(
|
||||||
|
instrument=params.instrument,
|
||||||
|
side=params.side,
|
||||||
|
amount=params.amount,
|
||||||
|
type=params.type,
|
||||||
|
price=params.price,
|
||||||
|
reduce_only=params.reduce_only,
|
||||||
|
)
|
||||||
|
# TODO V2: wire audit via request.state.environment in router
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_order(
|
||||||
|
client: HyperliquidClient, params: CancelOrderReq
|
||||||
|
) -> dict:
|
||||||
|
return await client.cancel_order(params.order_id, params.instrument)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_stop_loss(
|
||||||
|
client: HyperliquidClient, params: SetStopLossReq
|
||||||
|
) -> dict:
|
||||||
|
return await client.set_stop_loss(
|
||||||
|
params.instrument, params.stop_price, params.size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_take_profit(
|
||||||
|
client: HyperliquidClient, params: SetTakeProfitReq
|
||||||
|
) -> dict:
|
||||||
|
return await client.set_take_profit(
|
||||||
|
params.instrument, params.tp_price, params.size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_position(
|
||||||
|
client: HyperliquidClient, params: ClosePositionReq
|
||||||
|
) -> dict:
|
||||||
|
return await client.close_position(params.instrument)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Router /mcp-hyperliquid/* — DI per env, client e (write) creds.
|
||||||
|
|
||||||
|
Mappa 1:1 i tool di `cerbero_mcp.exchanges.hyperliquid.tools` a endpoint
|
||||||
|
`POST /mcp-hyperliquid/tools/{tool_name}`. L'autenticazione bearer è gestita
|
||||||
|
dal middleware in `cerbero_mcp.auth`; qui leggiamo solo `request.state.environment`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
|
from cerbero_mcp.client_registry import ClientRegistry
|
||||||
|
from cerbero_mcp.exchanges.hyperliquid import tools as t
|
||||||
|
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
|
||||||
|
|
||||||
|
Environment = Literal["testnet", "mainnet"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_environment(request: Request) -> Environment:
|
||||||
|
return request.state.environment
|
||||||
|
|
||||||
|
|
||||||
|
async def get_hyperliquid_client(
|
||||||
|
request: Request, env: Environment = Depends(get_environment)
|
||||||
|
) -> HyperliquidClient:
|
||||||
|
registry: ClientRegistry = request.app.state.registry
|
||||||
|
return await registry.get("hyperliquid", env)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_creds(request: Request) -> dict:
|
||||||
|
"""Costruisce dict `creds` minimale per leverage cap / metadata."""
|
||||||
|
settings = request.app.state.settings
|
||||||
|
return {
|
||||||
|
"max_leverage": settings.hyperliquid.max_leverage,
|
||||||
|
"wallet_address": settings.hyperliquid.wallet_address,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_router() -> APIRouter:
|
||||||
|
r = APIRouter(prefix="/mcp-hyperliquid", tags=["hyperliquid"])
|
||||||
|
|
||||||
|
# === READ tools ===
|
||||||
|
|
||||||
|
@r.post("/tools/environment_info")
|
||||||
|
async def _environment_info(
|
||||||
|
request: Request,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
creds = _build_creds(request)
|
||||||
|
return await t.environment_info(client, creds=creds)
|
||||||
|
|
||||||
|
@r.post("/tools/get_markets")
|
||||||
|
async def _get_markets(
|
||||||
|
params: t.GetMarketsReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.get_markets(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/get_ticker")
|
||||||
|
async def _get_ticker(
|
||||||
|
params: t.GetTickerReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.get_ticker(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/get_orderbook")
|
||||||
|
async def _get_orderbook(
|
||||||
|
params: t.GetOrderbookReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.get_orderbook(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/get_positions")
|
||||||
|
async def _get_positions(
|
||||||
|
params: t.GetPositionsReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.get_positions(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/get_account_summary")
|
||||||
|
async def _get_account_summary(
|
||||||
|
params: t.GetAccountSummaryReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.get_account_summary(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/get_trade_history")
|
||||||
|
async def _get_trade_history(
|
||||||
|
params: t.GetTradeHistoryReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.get_trade_history(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/get_historical")
|
||||||
|
async def _get_historical(
|
||||||
|
params: t.GetHistoricalReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.get_historical(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/get_open_orders")
|
||||||
|
async def _get_open_orders(
|
||||||
|
params: t.GetOpenOrdersReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.get_open_orders(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/get_funding_rate")
|
||||||
|
async def _get_funding_rate(
|
||||||
|
params: t.GetFundingRateReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.get_funding_rate(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/basis_spot_perp")
|
||||||
|
async def _basis_spot_perp(
|
||||||
|
params: t.BasisSpotPerpReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.basis_spot_perp(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/get_indicators")
|
||||||
|
async def _get_indicators(
|
||||||
|
params: t.GetIndicatorsReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.get_indicators(client, params)
|
||||||
|
|
||||||
|
# === WRITE tools (richiedono creds per leverage cap / audit) ===
|
||||||
|
|
||||||
|
@r.post("/tools/place_order")
|
||||||
|
async def _place_order(
|
||||||
|
params: t.PlaceOrderReq,
|
||||||
|
request: Request,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
creds = _build_creds(request)
|
||||||
|
return await t.place_order(client, params, creds=creds)
|
||||||
|
|
||||||
|
@r.post("/tools/cancel_order")
|
||||||
|
async def _cancel_order(
|
||||||
|
params: t.CancelOrderReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.cancel_order(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/set_stop_loss")
|
||||||
|
async def _set_stop_loss(
|
||||||
|
params: t.SetStopLossReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.set_stop_loss(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/set_take_profit")
|
||||||
|
async def _set_take_profit(
|
||||||
|
params: t.SetTakeProfitReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.set_take_profit(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/close_position")
|
||||||
|
async def _close_position(
|
||||||
|
params: t.ClosePositionReq,
|
||||||
|
client: HyperliquidClient = Depends(get_hyperliquid_client),
|
||||||
|
):
|
||||||
|
return await t.close_position(client, params)
|
||||||
|
|
||||||
|
return r
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
|
||||||
|
from pytest_httpx import HTTPXMock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return HyperliquidClient(
|
||||||
|
wallet_address="0xDeadBeef",
|
||||||
|
private_key="0x" + "a" * 64,
|
||||||
|
testnet=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Shared mock responses
|
||||||
|
|
||||||
|
META_AND_CTX = [
|
||||||
|
{
|
||||||
|
"universe": [
|
||||||
|
{"name": "BTC", "maxLeverage": 50},
|
||||||
|
{"name": "ETH", "maxLeverage": 25},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"markPx": "50000.0",
|
||||||
|
"funding": "0.0001",
|
||||||
|
"openInterest": "1000.0",
|
||||||
|
"dayNtlVlm": "500000.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"markPx": "3000.0",
|
||||||
|
"funding": "0.00005",
|
||||||
|
"openInterest": "500.0",
|
||||||
|
"dayNtlVlm": "200000.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
CLEARINGHOUSE_STATE = {
|
||||||
|
"marginSummary": {
|
||||||
|
"accountValue": "1500.0",
|
||||||
|
"totalRawUsd": "1200.0",
|
||||||
|
"totalMarginUsed": "300.0",
|
||||||
|
"totalNtlPos": "50.0",
|
||||||
|
},
|
||||||
|
"assetPositions": [
|
||||||
|
{
|
||||||
|
"position": {
|
||||||
|
"coin": "BTC",
|
||||||
|
"szi": "0.1",
|
||||||
|
"entryPx": "48000.0",
|
||||||
|
"unrealizedPnl": "200.0",
|
||||||
|
"leverage": {"value": "10"},
|
||||||
|
"liquidationPx": "40000.0",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SPOT_STATE = {"balances": [{"coin": "USDC", "total": "500.0"}]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_markets(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json=META_AND_CTX,
|
||||||
|
)
|
||||||
|
markets = await client.get_markets()
|
||||||
|
assert len(markets) == 2
|
||||||
|
assert markets[0]["asset"] == "BTC"
|
||||||
|
assert markets[0]["mark_price"] == 50000.0
|
||||||
|
assert markets[0]["max_leverage"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_ticker(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json=META_AND_CTX,
|
||||||
|
)
|
||||||
|
result = await client.get_ticker("BTC")
|
||||||
|
assert result["asset"] == "BTC"
|
||||||
|
assert result["mark_price"] == 50000.0
|
||||||
|
assert result["funding_rate"] == 0.0001
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_ticker_not_found(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json=META_AND_CTX,
|
||||||
|
)
|
||||||
|
result = await client.get_ticker("SOL")
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_orderbook(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json={
|
||||||
|
"levels": [
|
||||||
|
[{"px": "49990.0", "sz": "0.5"}, {"px": "49980.0", "sz": "1.0"}],
|
||||||
|
[{"px": "50010.0", "sz": "0.3"}, {"px": "50020.0", "sz": "0.8"}],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result = await client.get_orderbook("BTC", depth=2)
|
||||||
|
assert result["asset"] == "BTC"
|
||||||
|
assert len(result["bids"]) == 2
|
||||||
|
assert len(result["asks"]) == 2
|
||||||
|
assert result["bids"][0]["price"] == 49990.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_positions(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json=CLEARINGHOUSE_STATE,
|
||||||
|
)
|
||||||
|
positions = await client.get_positions()
|
||||||
|
assert len(positions) == 1
|
||||||
|
assert positions[0]["asset"] == "BTC"
|
||||||
|
assert positions[0]["direction"] == "long"
|
||||||
|
assert positions[0]["size"] == 0.1
|
||||||
|
assert positions[0]["leverage"] == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_account_summary(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
|
# get_account_summary calls /info twice (perp + spot)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json=CLEARINGHOUSE_STATE,
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json=SPOT_STATE,
|
||||||
|
)
|
||||||
|
result = await client.get_account_summary()
|
||||||
|
assert result["perps_equity"] == 1500.0
|
||||||
|
assert result["spot_usdc"] == 500.0
|
||||||
|
assert result["equity"] == 2000.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_trade_history(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json=[
|
||||||
|
{"coin": "BTC", "side": "B", "sz": "0.1", "px": "50000", "fee": "0.5", "time": 1000},
|
||||||
|
{"coin": "ETH", "side": "A", "sz": "1.0", "px": "3000", "fee": "0.3", "time": 2000},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
trades = await client.get_trade_history(limit=10)
|
||||||
|
assert len(trades) == 2
|
||||||
|
assert trades[0]["asset"] == "BTC"
|
||||||
|
assert trades[0]["price"] == 50000.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_open_orders(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json=[
|
||||||
|
{
|
||||||
|
"oid": 12345,
|
||||||
|
"coin": "BTC",
|
||||||
|
"side": "B",
|
||||||
|
"sz": "0.05",
|
||||||
|
"limitPx": "49000",
|
||||||
|
"orderType": "Limit",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
orders = await client.get_open_orders()
|
||||||
|
assert len(orders) == 1
|
||||||
|
assert orders[0]["oid"] == 12345
|
||||||
|
assert orders[0]["asset"] == "BTC"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json=[
|
||||||
|
{"t": 1000000, "o": "49000", "h": "51000", "l": "48500", "c": "50000", "v": "100"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = await client.get_historical("BTC", "2024-01-01", "2024-01-02", "1h")
|
||||||
|
assert len(result["candles"]) == 1
|
||||||
|
assert result["candles"][0]["close"] == 50000.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json={"universe": []},
|
||||||
|
)
|
||||||
|
result = await client.health()
|
||||||
|
assert result["status"] in ("ok", "healthy")
|
||||||
|
assert result["testnet"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_place_order_sdk_unavailable(client: HyperliquidClient):
|
||||||
|
"""place_order raises RuntimeError when SDK is not available (mocked)."""
|
||||||
|
import cerbero_mcp.exchanges.hyperliquid.client as mod
|
||||||
|
|
||||||
|
original = mod._SDK_AVAILABLE
|
||||||
|
mod._SDK_AVAILABLE = False
|
||||||
|
client._exchange = None
|
||||||
|
try:
|
||||||
|
result = await client.place_order("BTC", "buy", 0.1, price=50000.0)
|
||||||
|
# Should return error dict or raise RuntimeError
|
||||||
|
assert "error" in result or result.get("status") == "error"
|
||||||
|
except RuntimeError as exc:
|
||||||
|
assert "not installed" in str(exc).lower() or "sdk" in str(exc).lower()
|
||||||
|
finally:
|
||||||
|
mod._SDK_AVAILABLE = original
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from cerbero_mcp.exchanges.hyperliquid.leverage_cap import enforce_leverage, get_max_leverage
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_max_leverage_returns_creds_value():
|
||||||
|
creds = {"max_leverage": 5}
|
||||||
|
assert get_max_leverage(creds) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_max_leverage_default_when_missing():
|
||||||
|
"""Default 1 (cash) se il secret non ha max_leverage."""
|
||||||
|
assert get_max_leverage({}) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_enforce_leverage_pass_under_cap():
|
||||||
|
creds = {"max_leverage": 3}
|
||||||
|
enforce_leverage(2, creds=creds, exchange="hyperliquid") # no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_enforce_leverage_pass_at_cap():
|
||||||
|
creds = {"max_leverage": 3}
|
||||||
|
enforce_leverage(3, creds=creds, exchange="hyperliquid") # no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_enforce_leverage_reject_over_cap():
|
||||||
|
creds = {"max_leverage": 3}
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
enforce_leverage(10, creds=creds, exchange="hyperliquid")
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
assert exc.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED"
|
||||||
|
assert exc.value.detail["exchange"] == "hyperliquid"
|
||||||
|
assert exc.value.detail["requested"] == 10
|
||||||
|
assert exc.value.detail["max"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_enforce_leverage_reject_when_below_one():
|
||||||
|
creds = {"max_leverage": 3}
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
enforce_leverage(0, creds=creds, exchange="hyperliquid")
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_enforce_leverage_default_when_none():
|
||||||
|
"""Se requested è None, applica il cap come default."""
|
||||||
|
creds = {"max_leverage": 3}
|
||||||
|
result = enforce_leverage(None, creds=creds, exchange="hyperliquid")
|
||||||
|
assert result == 3
|
||||||
@@ -50,6 +50,27 @@ async def test_build_client_bybit_returns_correct_env(monkeypatch):
|
|||||||
assert c_live.testnet is False
|
assert c_live.testnet is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_build_client_hyperliquid_returns_correct_env(monkeypatch):
|
||||||
|
from tests.unit.test_settings import _minimal_env
|
||||||
|
|
||||||
|
for k, v in _minimal_env().items():
|
||||||
|
monkeypatch.setenv(k, v)
|
||||||
|
|
||||||
|
from cerbero_mcp.settings import Settings
|
||||||
|
from cerbero_mcp.exchanges import build_client
|
||||||
|
|
||||||
|
s = Settings()
|
||||||
|
c_test = await build_client(s, "hyperliquid", "testnet")
|
||||||
|
c_live = await build_client(s, "hyperliquid", "mainnet")
|
||||||
|
|
||||||
|
assert c_test is not c_live
|
||||||
|
assert c_test.testnet is True
|
||||||
|
assert c_live.testnet is False
|
||||||
|
assert "test" in c_test.base_url.lower()
|
||||||
|
assert "test" not in c_live.base_url.lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_build_client_unknown_exchange_raises(monkeypatch):
|
async def test_build_client_unknown_exchange_raises(monkeypatch):
|
||||||
from tests.unit.test_settings import _minimal_env
|
from tests.unit.test_settings import _minimal_env
|
||||||
|
|||||||
Reference in New Issue
Block a user