feat: import 6 MCP services + common workspace
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from mcp_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 mcp_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,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from mcp_hyperliquid.server import create_app
|
||||
from option_mcp_common.auth import Principal, TokenStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
c = MagicMock()
|
||||
c.get_markets = AsyncMock(return_value=[{"asset": "BTC", "mark_price": 50000}])
|
||||
c.get_ticker = AsyncMock(return_value={"asset": "BTC", "mark_price": 50000})
|
||||
c.get_orderbook = AsyncMock(return_value={"bids": [], "asks": []})
|
||||
c.get_positions = AsyncMock(return_value=[])
|
||||
c.get_account_summary = AsyncMock(return_value={"equity": 1500, "perps_equity": 1000})
|
||||
c.get_trade_history = AsyncMock(return_value=[])
|
||||
c.get_historical = AsyncMock(return_value={"candles": []})
|
||||
c.get_open_orders = AsyncMock(return_value=[])
|
||||
c.get_funding_rate = AsyncMock(return_value={"asset": "BTC", "current_funding_rate": 0.0001})
|
||||
c.get_indicators = AsyncMock(return_value={"rsi": 55.0})
|
||||
c.place_order = AsyncMock(return_value={"order_id": "x", "status": "ok"})
|
||||
c.cancel_order = AsyncMock(return_value={"order_id": "x", "status": "ok"})
|
||||
c.set_stop_loss = AsyncMock(return_value={"order_id": "x", "status": "ok"})
|
||||
c.set_take_profit = AsyncMock(return_value={"order_id": "x", "status": "ok"})
|
||||
c.close_position = AsyncMock(return_value={"status": "ok", "asset": "BTC"})
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http(mock_client):
|
||||
store = TokenStore(
|
||||
tokens={
|
||||
"ct": Principal("core", {"core"}),
|
||||
"ot": Principal("observer", {"observer"}),
|
||||
}
|
||||
)
|
||||
app = create_app(client=mock_client, token_store=store)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# --- Health ---
|
||||
|
||||
def test_health(http):
|
||||
assert http.get("/health").status_code == 200
|
||||
|
||||
|
||||
# --- Read tools: both core and observer allowed ---
|
||||
|
||||
def test_get_markets_core_ok(http):
|
||||
r = http.post("/tools/get_markets", headers={"Authorization": "Bearer ct"}, json={})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_get_markets_observer_ok(http):
|
||||
r = http.post("/tools/get_markets", headers={"Authorization": "Bearer ot"}, json={})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_get_ticker_core_ok(http):
|
||||
r = http.post(
|
||||
"/tools/get_ticker",
|
||||
headers={"Authorization": "Bearer ct"},
|
||||
json={"instrument": "BTC"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["mark_price"] == 50000
|
||||
|
||||
|
||||
def test_get_ticker_observer_ok(http):
|
||||
r = http.post(
|
||||
"/tools/get_ticker",
|
||||
headers={"Authorization": "Bearer ot"},
|
||||
json={"instrument": "BTC"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_get_ticker_no_auth_401(http):
|
||||
r = http.post("/tools/get_ticker", json={"instrument": "BTC"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_get_account_summary_observer_ok(http):
|
||||
r = http.post(
|
||||
"/tools/get_account_summary",
|
||||
headers={"Authorization": "Bearer ot"},
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["equity"] == 1500
|
||||
|
||||
|
||||
def test_get_funding_rate_observer_ok(http):
|
||||
r = http.post(
|
||||
"/tools/get_funding_rate",
|
||||
headers={"Authorization": "Bearer ot"},
|
||||
json={"instrument": "BTC"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_get_positions_no_auth_401(http):
|
||||
r = http.post("/tools/get_positions", json={})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# --- Write tools: core only ---
|
||||
|
||||
def test_place_order_core_ok(http):
|
||||
# CER-016: amount * price = 150 < cap 200
|
||||
r = http.post(
|
||||
"/tools/place_order",
|
||||
headers={"Authorization": "Bearer ct"},
|
||||
json={"instrument": "BTC", "side": "buy", "amount": 0.003, "price": 50000},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_place_order_observer_forbidden(http):
|
||||
r = http.post(
|
||||
"/tools/place_order",
|
||||
headers={"Authorization": "Bearer ot"},
|
||||
json={"instrument": "BTC", "side": "buy", "amount": 0.001, "price": 50000},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_place_order_notional_cap_enforced(http):
|
||||
"""CER-016: HL reject amount*price > 200."""
|
||||
r = http.post(
|
||||
"/tools/place_order",
|
||||
headers={"Authorization": "Bearer ct"},
|
||||
json={"instrument": "ETH", "side": "buy", "amount": 0.1, "price": 3350},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert r.json()["error"]["code"] == "HARD_PROHIBITION"
|
||||
|
||||
|
||||
def test_place_order_leverage_cap_enforced_hl(http):
|
||||
r = http.post(
|
||||
"/tools/place_order",
|
||||
headers={"Authorization": "Bearer ct"},
|
||||
json={
|
||||
"instrument": "BTC",
|
||||
"side": "buy",
|
||||
"amount": 0.001,
|
||||
"price": 50000,
|
||||
"leverage": 10,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_cancel_order_core_ok(http):
|
||||
r = http.post(
|
||||
"/tools/cancel_order",
|
||||
headers={"Authorization": "Bearer ct"},
|
||||
json={"order_id": "123", "instrument": "BTC"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_cancel_order_observer_forbidden(http):
|
||||
r = http.post(
|
||||
"/tools/cancel_order",
|
||||
headers={"Authorization": "Bearer ot"},
|
||||
json={"order_id": "123", "instrument": "BTC"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_set_stop_loss_core_ok(http):
|
||||
r = http.post(
|
||||
"/tools/set_stop_loss",
|
||||
headers={"Authorization": "Bearer ct"},
|
||||
json={"instrument": "BTC", "stop_price": 45000.0, "size": 0.1},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_set_stop_loss_observer_forbidden(http):
|
||||
r = http.post(
|
||||
"/tools/set_stop_loss",
|
||||
headers={"Authorization": "Bearer ot"},
|
||||
json={"instrument": "BTC", "stop_price": 45000.0, "size": 0.1},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_set_take_profit_observer_forbidden(http):
|
||||
r = http.post(
|
||||
"/tools/set_take_profit",
|
||||
headers={"Authorization": "Bearer ot"},
|
||||
json={"instrument": "BTC", "tp_price": 55000.0, "size": 0.1},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_close_position_core_ok(http):
|
||||
r = http.post(
|
||||
"/tools/close_position",
|
||||
headers={"Authorization": "Bearer ct"},
|
||||
json={"instrument": "BTC"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_close_position_observer_forbidden(http):
|
||||
r = http.post(
|
||||
"/tools/close_position",
|
||||
headers={"Authorization": "Bearer ot"},
|
||||
json={"instrument": "BTC"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
Reference in New Issue
Block a user