feat: import 6 MCP services + common workspace
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
[project]
|
||||
name = "mcp-bybit"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"option-mcp-common",
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.6",
|
||||
"pybit>=5.8",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mcp_bybit"]
|
||||
|
||||
[tool.uv.sources]
|
||||
option-mcp-common = { workspace = true }
|
||||
|
||||
[project.scripts]
|
||||
mcp-bybit = "mcp_bybit.__main__:main"
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from option_mcp_common.auth import load_token_store_from_files
|
||||
from option_mcp_common.logging import configure_root_logging
|
||||
|
||||
from mcp_bybit.client import BybitClient
|
||||
from mcp_bybit.server import create_app
|
||||
|
||||
|
||||
configure_root_logging()
|
||||
|
||||
|
||||
def main():
|
||||
creds_file = os.environ["BYBIT_CREDENTIALS_FILE"]
|
||||
with open(creds_file) as f:
|
||||
creds = json.load(f)
|
||||
|
||||
testnet_env = os.environ.get("BYBIT_TESTNET", "true").lower()
|
||||
testnet = testnet_env not in ("0", "false", "no")
|
||||
|
||||
client = BybitClient(
|
||||
api_key=creds["api_key"],
|
||||
api_secret=creds["api_secret"],
|
||||
testnet=testnet,
|
||||
)
|
||||
|
||||
token_store = load_token_store_from_files(
|
||||
core_token_file=os.environ.get("CORE_TOKEN_FILE"),
|
||||
observer_token_file=os.environ.get("OBSERVER_TOKEN_FILE"),
|
||||
)
|
||||
app = create_app(client=client, token_store=token_store)
|
||||
uvicorn.run(
|
||||
app,
|
||||
log_config=None,
|
||||
host=os.environ.get("HOST", "0.0.0.0"),
|
||||
port=int(os.environ.get("PORT", "9019")),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,558 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from option_mcp_common import indicators as ind
|
||||
from pybit.unified_trading import HTTP
|
||||
|
||||
|
||||
def _f(v: Any) -> float | None:
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _i(v: Any) -> int | None:
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class BybitClient:
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
api_secret: str,
|
||||
testnet: bool = True,
|
||||
http: Any | None = None,
|
||||
) -> None:
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret
|
||||
self.testnet = testnet
|
||||
self._http = http or HTTP(
|
||||
api_key=api_key,
|
||||
api_secret=api_secret,
|
||||
testnet=testnet,
|
||||
)
|
||||
|
||||
async def _run(self, fn, /, **kwargs):
|
||||
return await asyncio.to_thread(fn, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _parse_ticker(row: dict) -> dict:
|
||||
return {
|
||||
"symbol": row.get("symbol"),
|
||||
"last_price": _f(row.get("lastPrice")),
|
||||
"mark_price": _f(row.get("markPrice")),
|
||||
"bid": _f(row.get("bid1Price")),
|
||||
"ask": _f(row.get("ask1Price")),
|
||||
"volume_24h": _f(row.get("volume24h")),
|
||||
"turnover_24h": _f(row.get("turnover24h")),
|
||||
"funding_rate": _f(row.get("fundingRate")),
|
||||
"open_interest": _f(row.get("openInterest")),
|
||||
}
|
||||
|
||||
async def get_ticker(self, symbol: str, category: str = "linear") -> dict:
|
||||
resp = await self._run(
|
||||
self._http.get_tickers, category=category, symbol=symbol
|
||||
)
|
||||
rows = (resp.get("result") or {}).get("list") or []
|
||||
if not rows:
|
||||
return {"symbol": symbol, "error": "not_found"}
|
||||
return self._parse_ticker(rows[0])
|
||||
|
||||
async def get_ticker_batch(
|
||||
self, symbols: list[str], category: str = "linear"
|
||||
) -> dict[str, dict]:
|
||||
out: dict[str, dict] = {}
|
||||
for sym in symbols:
|
||||
out[sym] = await self.get_ticker(sym, category=category)
|
||||
return out
|
||||
|
||||
async def get_orderbook(
|
||||
self, symbol: str, category: str = "linear", limit: int = 50
|
||||
) -> dict:
|
||||
resp = await self._run(
|
||||
self._http.get_orderbook, category=category, symbol=symbol, limit=limit
|
||||
)
|
||||
r = resp.get("result") or {}
|
||||
return {
|
||||
"symbol": r.get("s"),
|
||||
"bids": [[float(p), float(q)] for p, q in (r.get("b") or [])],
|
||||
"asks": [[float(p), float(q)] for p, q in (r.get("a") or [])],
|
||||
"timestamp": r.get("ts"),
|
||||
}
|
||||
|
||||
async def get_historical(
|
||||
self,
|
||||
symbol: str,
|
||||
category: str = "linear",
|
||||
interval: str = "60",
|
||||
start: int | None = None,
|
||||
end: int | None = None,
|
||||
limit: int = 1000,
|
||||
) -> dict:
|
||||
kwargs = dict(
|
||||
category=category,
|
||||
symbol=symbol,
|
||||
interval=interval,
|
||||
limit=limit,
|
||||
)
|
||||
if start is not None:
|
||||
kwargs["start"] = start
|
||||
if end is not None:
|
||||
kwargs["end"] = end
|
||||
resp = await self._run(self._http.get_kline, **kwargs)
|
||||
rows = (resp.get("result") or {}).get("list") or []
|
||||
rows_sorted = sorted(rows, key=lambda r: int(r[0]))
|
||||
candles = [
|
||||
{
|
||||
"timestamp": int(r[0]),
|
||||
"open": float(r[1]),
|
||||
"high": float(r[2]),
|
||||
"low": float(r[3]),
|
||||
"close": float(r[4]),
|
||||
"volume": float(r[5]),
|
||||
}
|
||||
for r in rows_sorted
|
||||
]
|
||||
return {"symbol": symbol, "candles": candles}
|
||||
|
||||
async def get_indicators(
|
||||
self,
|
||||
symbol: str,
|
||||
category: str = "linear",
|
||||
indicators: list[str] | None = None,
|
||||
interval: str = "60",
|
||||
start: int | None = None,
|
||||
end: int | None = None,
|
||||
) -> dict:
|
||||
indicators = indicators or ["rsi", "atr", "macd", "adx"]
|
||||
historical = await self.get_historical(
|
||||
symbol, category=category, interval=interval, start=start, end=end
|
||||
)
|
||||
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]
|
||||
|
||||
out: dict[str, Any] = {"symbol": symbol, "category": category}
|
||||
for name in indicators:
|
||||
n = name.lower()
|
||||
if n == "sma":
|
||||
out["sma"] = ind.sma(closes, 20)
|
||||
elif n == "rsi":
|
||||
out["rsi"] = ind.rsi(closes)
|
||||
elif n == "atr":
|
||||
out["atr"] = ind.atr(highs, lows, closes)
|
||||
elif n == "macd":
|
||||
out["macd"] = ind.macd(closes)
|
||||
elif n == "adx":
|
||||
out["adx"] = ind.adx(highs, lows, closes)
|
||||
else:
|
||||
out[n] = None
|
||||
return out
|
||||
|
||||
async def get_funding_rate(self, symbol: str, category: str = "linear") -> dict:
|
||||
resp = await self._run(
|
||||
self._http.get_tickers, category=category, symbol=symbol
|
||||
)
|
||||
rows = (resp.get("result") or {}).get("list") or []
|
||||
if not rows:
|
||||
return {"symbol": symbol, "error": "not_found"}
|
||||
row = rows[0]
|
||||
return {
|
||||
"symbol": row.get("symbol"),
|
||||
"funding_rate": _f(row.get("fundingRate")),
|
||||
"next_funding_time": _i(row.get("nextFundingTime")),
|
||||
}
|
||||
|
||||
async def get_funding_history(
|
||||
self, symbol: str, category: str = "linear", limit: int = 100
|
||||
) -> dict:
|
||||
resp = await self._run(
|
||||
self._http.get_funding_rate_history,
|
||||
category=category, symbol=symbol, limit=limit,
|
||||
)
|
||||
rows = (resp.get("result") or {}).get("list") or []
|
||||
hist = [
|
||||
{
|
||||
"timestamp": int(r.get("fundingRateTimestamp", 0)),
|
||||
"rate": float(r.get("fundingRate", 0)),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"symbol": symbol, "history": hist}
|
||||
|
||||
async def get_open_interest(
|
||||
self,
|
||||
symbol: str,
|
||||
category: str = "linear",
|
||||
interval: str = "5min",
|
||||
limit: int = 288,
|
||||
) -> dict:
|
||||
resp = await self._run(
|
||||
self._http.get_open_interest,
|
||||
category=category, symbol=symbol, intervalTime=interval, limit=limit,
|
||||
)
|
||||
rows = (resp.get("result") or {}).get("list") or []
|
||||
points = [
|
||||
{
|
||||
"timestamp": int(r.get("timestamp", 0)),
|
||||
"oi": float(r.get("openInterest", 0)),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
current_oi = points[0]["oi"] if points else None
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"category": category,
|
||||
"interval": interval,
|
||||
"current_oi": current_oi,
|
||||
"points": points,
|
||||
}
|
||||
|
||||
async def get_instruments(self, category: str = "linear", symbol: str | None = None) -> dict:
|
||||
kwargs: dict[str, Any] = {"category": category}
|
||||
if symbol:
|
||||
kwargs["symbol"] = symbol
|
||||
resp = await self._run(self._http.get_instruments_info, **kwargs)
|
||||
rows = (resp.get("result") or {}).get("list") or []
|
||||
instruments = []
|
||||
for r in rows:
|
||||
pf = r.get("priceFilter") or {}
|
||||
lf = r.get("lotSizeFilter") or {}
|
||||
instruments.append({
|
||||
"symbol": r.get("symbol"),
|
||||
"status": r.get("status"),
|
||||
"base_coin": r.get("baseCoin"),
|
||||
"quote_coin": r.get("quoteCoin"),
|
||||
"tick_size": _f(pf.get("tickSize")),
|
||||
"qty_step": _f(lf.get("qtyStep")),
|
||||
"min_qty": _f(lf.get("minOrderQty")),
|
||||
})
|
||||
return {"category": category, "instruments": instruments}
|
||||
|
||||
async def get_option_chain(self, base_coin: str, expiry: str | None = None) -> dict:
|
||||
kwargs: dict[str, Any] = {"category": "option", "baseCoin": base_coin.upper()}
|
||||
resp = await self._run(self._http.get_instruments_info, **kwargs)
|
||||
rows = (resp.get("result") or {}).get("list") or []
|
||||
options = []
|
||||
for r in rows:
|
||||
delivery = r.get("deliveryTime")
|
||||
if expiry and expiry not in r.get("symbol", ""):
|
||||
continue
|
||||
options.append({
|
||||
"symbol": r.get("symbol"),
|
||||
"base_coin": r.get("baseCoin"),
|
||||
"settle_coin": r.get("settleCoin"),
|
||||
"type": r.get("optionsType"),
|
||||
"launch_time": int(r.get("launchTime", 0)),
|
||||
"delivery_time": int(delivery) if delivery else None,
|
||||
})
|
||||
return {"base_coin": base_coin.upper(), "options": options}
|
||||
|
||||
async def get_positions(
|
||||
self, category: str = "linear", settle_coin: str = "USDT"
|
||||
) -> list[dict]:
|
||||
kwargs: dict[str, Any] = {"category": category}
|
||||
if category in ("linear", "inverse"):
|
||||
kwargs["settleCoin"] = settle_coin
|
||||
resp = await self._run(self._http.get_positions, **kwargs)
|
||||
rows = (resp.get("result") or {}).get("list") or []
|
||||
out = []
|
||||
for r in rows:
|
||||
out.append({
|
||||
"symbol": r.get("symbol"),
|
||||
"side": r.get("side"),
|
||||
"size": _f(r.get("size")),
|
||||
"entry_price": _f(r.get("avgPrice")),
|
||||
"unrealized_pnl": _f(r.get("unrealisedPnl")),
|
||||
"leverage": _f(r.get("leverage")),
|
||||
"liquidation_price": _f(r.get("liqPrice")),
|
||||
"position_value": _f(r.get("positionValue")),
|
||||
})
|
||||
return out
|
||||
|
||||
async def get_account_summary(self, account_type: str = "UNIFIED") -> dict:
|
||||
resp = await self._run(
|
||||
self._http.get_wallet_balance, accountType=account_type
|
||||
)
|
||||
rows = (resp.get("result") or {}).get("list") or []
|
||||
if not rows:
|
||||
return {"error": "no_account"}
|
||||
a = rows[0]
|
||||
coins = []
|
||||
for c in a.get("coin") or []:
|
||||
coins.append({
|
||||
"coin": c.get("coin"),
|
||||
"wallet_balance": _f(c.get("walletBalance")),
|
||||
"equity": _f(c.get("equity")),
|
||||
})
|
||||
return {
|
||||
"account_type": a.get("accountType"),
|
||||
"equity": _f(a.get("totalEquity")),
|
||||
"wallet_balance": _f(a.get("totalWalletBalance")),
|
||||
"margin_balance": _f(a.get("totalMarginBalance")),
|
||||
"available_balance": _f(a.get("totalAvailableBalance")),
|
||||
"unrealized_pnl": _f(a.get("totalPerpUPL")),
|
||||
"coins": coins,
|
||||
}
|
||||
|
||||
async def get_trade_history(
|
||||
self, category: str = "linear", limit: int = 50
|
||||
) -> list[dict]:
|
||||
resp = await self._run(
|
||||
self._http.get_executions, category=category, limit=limit
|
||||
)
|
||||
rows = (resp.get("result") or {}).get("list") or []
|
||||
return [
|
||||
{
|
||||
"symbol": r.get("symbol"),
|
||||
"side": r.get("side"),
|
||||
"size": _f(r.get("execQty")),
|
||||
"price": _f(r.get("execPrice")),
|
||||
"fee": _f(r.get("execFee")),
|
||||
"timestamp": _i(r.get("execTime")),
|
||||
"order_id": r.get("orderId"),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
async def get_open_orders(
|
||||
self,
|
||||
category: str = "linear",
|
||||
symbol: str | None = None,
|
||||
settle_coin: str = "USDT",
|
||||
) -> list[dict]:
|
||||
kwargs: dict[str, Any] = {"category": category}
|
||||
if category in ("linear", "inverse") and not symbol:
|
||||
kwargs["settleCoin"] = settle_coin
|
||||
if symbol:
|
||||
kwargs["symbol"] = symbol
|
||||
resp = await self._run(self._http.get_open_orders, **kwargs)
|
||||
rows = (resp.get("result") or {}).get("list") or []
|
||||
return [
|
||||
{
|
||||
"order_id": r.get("orderId"),
|
||||
"symbol": r.get("symbol"),
|
||||
"side": r.get("side"),
|
||||
"qty": _f(r.get("qty")),
|
||||
"price": _f(r.get("price")),
|
||||
"type": r.get("orderType"),
|
||||
"status": r.get("orderStatus"),
|
||||
"reduce_only": bool(r.get("reduceOnly")),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
async def get_basis_spot_perp(self, asset: str) -> dict:
|
||||
asset = asset.upper()
|
||||
symbol = f"{asset}USDT"
|
||||
spot = await self.get_ticker(symbol, category="spot")
|
||||
perp = await self.get_ticker(symbol, category="linear")
|
||||
sp = spot.get("last_price")
|
||||
pp = perp.get("last_price")
|
||||
basis_abs = basis_pct = None
|
||||
if sp and pp:
|
||||
basis_abs = pp - sp
|
||||
basis_pct = 100.0 * basis_abs / sp
|
||||
return {
|
||||
"asset": asset,
|
||||
"symbol": symbol,
|
||||
"spot_price": sp,
|
||||
"perp_price": pp,
|
||||
"basis_abs": basis_abs,
|
||||
"basis_pct": basis_pct,
|
||||
"funding_rate": perp.get("funding_rate"),
|
||||
}
|
||||
|
||||
def _envelope(self, resp: dict, payload: dict) -> dict:
|
||||
code = resp.get("retCode", 0)
|
||||
if code != 0:
|
||||
return {"error": resp.get("retMsg", "bybit_error"), "code": code}
|
||||
return payload
|
||||
|
||||
async def place_order(
|
||||
self,
|
||||
category: str,
|
||||
symbol: str,
|
||||
side: str,
|
||||
qty: float,
|
||||
order_type: str = "Limit",
|
||||
price: float | None = None,
|
||||
tif: str = "GTC",
|
||||
reduce_only: bool = False,
|
||||
position_idx: int | None = None,
|
||||
) -> dict:
|
||||
kwargs: dict[str, Any] = {
|
||||
"category": category,
|
||||
"symbol": symbol,
|
||||
"side": side,
|
||||
"qty": str(qty),
|
||||
"orderType": order_type,
|
||||
"timeInForce": tif,
|
||||
"reduceOnly": reduce_only,
|
||||
}
|
||||
if price is not None:
|
||||
kwargs["price"] = str(price)
|
||||
if position_idx is not None:
|
||||
kwargs["positionIdx"] = position_idx
|
||||
if category == "option":
|
||||
import uuid
|
||||
kwargs["orderLinkId"] = f"cerbero-{uuid.uuid4().hex[:16]}"
|
||||
resp = await self._run(self._http.place_order, **kwargs)
|
||||
r = resp.get("result") or {}
|
||||
return self._envelope(resp, {
|
||||
"order_id": r.get("orderId"),
|
||||
"order_link_id": r.get("orderLinkId"),
|
||||
"status": "submitted",
|
||||
})
|
||||
|
||||
async def amend_order(
|
||||
self,
|
||||
category: str,
|
||||
symbol: str,
|
||||
order_id: str,
|
||||
new_qty: float | None = None,
|
||||
new_price: float | None = None,
|
||||
) -> dict:
|
||||
kwargs: dict[str, Any] = {
|
||||
"category": category,
|
||||
"symbol": symbol,
|
||||
"orderId": order_id,
|
||||
}
|
||||
if new_qty is not None:
|
||||
kwargs["qty"] = str(new_qty)
|
||||
if new_price is not None:
|
||||
kwargs["price"] = str(new_price)
|
||||
resp = await self._run(self._http.amend_order, **kwargs)
|
||||
r = resp.get("result") or {}
|
||||
return self._envelope(resp, {
|
||||
"order_id": r.get("orderId", order_id),
|
||||
"status": "amended",
|
||||
})
|
||||
|
||||
async def cancel_order(
|
||||
self, category: str, symbol: str, order_id: str
|
||||
) -> dict:
|
||||
resp = await self._run(
|
||||
self._http.cancel_order,
|
||||
category=category, symbol=symbol, orderId=order_id,
|
||||
)
|
||||
r = resp.get("result") or {}
|
||||
return self._envelope(resp, {
|
||||
"order_id": r.get("orderId", order_id),
|
||||
"status": "cancelled",
|
||||
})
|
||||
|
||||
async def cancel_all_orders(
|
||||
self, category: str, symbol: str | None = None
|
||||
) -> dict:
|
||||
kwargs: dict[str, Any] = {"category": category}
|
||||
if symbol:
|
||||
kwargs["symbol"] = symbol
|
||||
resp = await self._run(self._http.cancel_all_orders, **kwargs)
|
||||
r = resp.get("result") or {}
|
||||
ids = [x.get("orderId") for x in (r.get("list") or [])]
|
||||
return self._envelope(resp, {
|
||||
"cancelled_ids": ids,
|
||||
"count": len(ids),
|
||||
})
|
||||
|
||||
async def set_stop_loss(
|
||||
self, category: str, symbol: str, stop_loss: float,
|
||||
position_idx: int = 0,
|
||||
) -> dict:
|
||||
resp = await self._run(
|
||||
self._http.set_trading_stop,
|
||||
category=category, symbol=symbol,
|
||||
stopLoss=str(stop_loss), positionIdx=position_idx,
|
||||
)
|
||||
return self._envelope(resp, {
|
||||
"symbol": symbol, "stop_loss": stop_loss,
|
||||
"status": "stop_loss_set",
|
||||
})
|
||||
|
||||
async def set_take_profit(
|
||||
self, category: str, symbol: str, take_profit: float,
|
||||
position_idx: int = 0,
|
||||
) -> dict:
|
||||
resp = await self._run(
|
||||
self._http.set_trading_stop,
|
||||
category=category, symbol=symbol,
|
||||
takeProfit=str(take_profit), positionIdx=position_idx,
|
||||
)
|
||||
return self._envelope(resp, {
|
||||
"symbol": symbol, "take_profit": take_profit,
|
||||
"status": "take_profit_set",
|
||||
})
|
||||
|
||||
async def close_position(self, category: str, symbol: str) -> dict:
|
||||
positions = await self.get_positions(category=category)
|
||||
target = next((p for p in positions if p["symbol"] == symbol and (p["size"] or 0) > 0), None)
|
||||
if not target:
|
||||
return {"error": "no_open_position", "symbol": symbol}
|
||||
close_side = "Sell" if target["side"] == "Buy" else "Buy"
|
||||
return await self.place_order(
|
||||
category=category,
|
||||
symbol=symbol,
|
||||
side=close_side,
|
||||
qty=target["size"],
|
||||
order_type="Market",
|
||||
reduce_only=True,
|
||||
tif="IOC",
|
||||
)
|
||||
|
||||
async def set_leverage(
|
||||
self, category: str, symbol: str, leverage: int
|
||||
) -> dict:
|
||||
resp = await self._run(
|
||||
self._http.set_leverage,
|
||||
category=category, symbol=symbol,
|
||||
buyLeverage=str(leverage), sellLeverage=str(leverage),
|
||||
)
|
||||
return self._envelope(resp, {
|
||||
"symbol": symbol, "leverage": leverage,
|
||||
"status": "leverage_set",
|
||||
})
|
||||
|
||||
async def switch_position_mode(
|
||||
self, category: str, symbol: str, mode: str
|
||||
) -> dict:
|
||||
mode_code = 3 if mode.lower() == "hedge" else 0
|
||||
resp = await self._run(
|
||||
self._http.switch_position_mode,
|
||||
category=category, symbol=symbol, mode=mode_code,
|
||||
)
|
||||
return self._envelope(resp, {
|
||||
"symbol": symbol, "mode": mode,
|
||||
"status": "mode_switched",
|
||||
})
|
||||
|
||||
async def transfer_asset(
|
||||
self,
|
||||
coin: str,
|
||||
amount: float,
|
||||
from_type: str,
|
||||
to_type: str,
|
||||
) -> dict:
|
||||
import uuid
|
||||
resp = await self._run(
|
||||
self._http.create_internal_transfer,
|
||||
transferId=str(uuid.uuid4()),
|
||||
coin=coin,
|
||||
amount=str(amount),
|
||||
fromAccountType=from_type,
|
||||
toAccountType=to_type,
|
||||
)
|
||||
r = resp.get("result") or {}
|
||||
return self._envelope(resp, {
|
||||
"transfer_id": r.get("transferId"),
|
||||
"coin": coin,
|
||||
"amount": amount,
|
||||
"status": "submitted",
|
||||
})
|
||||
@@ -0,0 +1,363 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from option_mcp_common.auth import Principal, TokenStore, require_principal
|
||||
from option_mcp_common.mcp_bridge import mount_mcp_endpoint
|
||||
from option_mcp_common.server import build_app
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp_bybit.client import BybitClient
|
||||
|
||||
|
||||
# --- Body models: reads ---
|
||||
|
||||
class TickerReq(BaseModel):
|
||||
symbol: str
|
||||
category: str = "linear"
|
||||
|
||||
|
||||
class TickerBatchReq(BaseModel):
|
||||
symbols: list[str]
|
||||
category: str = "linear"
|
||||
|
||||
|
||||
class OrderbookReq(BaseModel):
|
||||
symbol: str
|
||||
category: str = "linear"
|
||||
limit: int = 50
|
||||
|
||||
|
||||
class HistoricalReq(BaseModel):
|
||||
symbol: str
|
||||
category: str = "linear"
|
||||
interval: str = "60"
|
||||
start: int | None = None
|
||||
end: int | None = None
|
||||
limit: int = 1000
|
||||
|
||||
|
||||
class IndicatorsReq(BaseModel):
|
||||
symbol: str
|
||||
category: str = "linear"
|
||||
indicators: list[str] = ["rsi", "atr", "macd", "adx"]
|
||||
interval: str = "60"
|
||||
start: int | None = None
|
||||
end: int | None = None
|
||||
|
||||
|
||||
class FundingRateReq(BaseModel):
|
||||
symbol: str
|
||||
category: str = "linear"
|
||||
|
||||
|
||||
class FundingHistoryReq(BaseModel):
|
||||
symbol: str
|
||||
category: str = "linear"
|
||||
limit: int = 100
|
||||
|
||||
|
||||
class OpenInterestReq(BaseModel):
|
||||
symbol: str
|
||||
category: str = "linear"
|
||||
interval: str = "5min"
|
||||
limit: int = 288
|
||||
|
||||
|
||||
class InstrumentsReq(BaseModel):
|
||||
category: str = "linear"
|
||||
symbol: str | None = None
|
||||
|
||||
|
||||
class OptionChainReq(BaseModel):
|
||||
base_coin: str
|
||||
expiry: str | None = None
|
||||
|
||||
|
||||
class PositionsReq(BaseModel):
|
||||
category: str = "linear"
|
||||
|
||||
|
||||
class AccountSummaryReq(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class TradeHistoryReq(BaseModel):
|
||||
category: str = "linear"
|
||||
limit: int = 50
|
||||
|
||||
|
||||
class OpenOrdersReq(BaseModel):
|
||||
category: str = "linear"
|
||||
symbol: str | None = None
|
||||
|
||||
|
||||
class BasisSpotPerpReq(BaseModel):
|
||||
asset: str
|
||||
|
||||
|
||||
# --- Body models: writes ---
|
||||
|
||||
class PlaceOrderReq(BaseModel):
|
||||
category: str
|
||||
symbol: str
|
||||
side: str
|
||||
qty: float
|
||||
order_type: str = "Limit"
|
||||
price: float | None = None
|
||||
tif: str = "GTC"
|
||||
reduce_only: bool = False
|
||||
position_idx: int | None = None
|
||||
|
||||
|
||||
class AmendOrderReq(BaseModel):
|
||||
category: str
|
||||
symbol: str
|
||||
order_id: str
|
||||
new_qty: float | None = None
|
||||
new_price: float | None = None
|
||||
|
||||
|
||||
class CancelOrderReq(BaseModel):
|
||||
category: str
|
||||
symbol: str
|
||||
order_id: str
|
||||
|
||||
|
||||
class CancelAllReq(BaseModel):
|
||||
category: str
|
||||
symbol: str | None = None
|
||||
|
||||
|
||||
class SetStopLossReq(BaseModel):
|
||||
category: str
|
||||
symbol: str
|
||||
stop_loss: float
|
||||
position_idx: int = 0
|
||||
|
||||
|
||||
class SetTakeProfitReq(BaseModel):
|
||||
category: str
|
||||
symbol: str
|
||||
take_profit: float
|
||||
position_idx: int = 0
|
||||
|
||||
|
||||
class ClosePositionReq(BaseModel):
|
||||
category: str
|
||||
symbol: str
|
||||
|
||||
|
||||
class SetLeverageReq(BaseModel):
|
||||
category: str
|
||||
symbol: str
|
||||
leverage: int
|
||||
|
||||
|
||||
class SwitchModeReq(BaseModel):
|
||||
category: str
|
||||
symbol: str
|
||||
mode: str
|
||||
|
||||
|
||||
class TransferReq(BaseModel):
|
||||
coin: str
|
||||
amount: float
|
||||
from_type: str
|
||||
to_type: str
|
||||
|
||||
|
||||
# --- ACL helper ---
|
||||
|
||||
def _check(principal: Principal, *, core: bool = False, observer: bool = False) -> None:
|
||||
allowed: set[str] = set()
|
||||
if core:
|
||||
allowed.add("core")
|
||||
if observer:
|
||||
allowed.add("observer")
|
||||
if not (principal.capabilities & allowed):
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
|
||||
|
||||
def create_app(*, client: BybitClient, token_store: TokenStore):
|
||||
app = build_app(name="mcp-bybit", version="0.1.0", token_store=token_store)
|
||||
|
||||
# ── Reads ──────────────────────────────────────────────
|
||||
|
||||
@app.post("/tools/get_ticker", tags=["reads"])
|
||||
async def t_get_ticker(body: TickerReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await client.get_ticker(body.symbol, body.category)
|
||||
|
||||
@app.post("/tools/get_ticker_batch", tags=["reads"])
|
||||
async def t_get_ticker_batch(body: TickerBatchReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await client.get_ticker_batch(body.symbols, body.category)
|
||||
|
||||
@app.post("/tools/get_orderbook", tags=["reads"])
|
||||
async def t_get_orderbook(body: OrderbookReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await client.get_orderbook(body.symbol, body.category, body.limit)
|
||||
|
||||
@app.post("/tools/get_historical", tags=["reads"])
|
||||
async def t_get_historical(body: HistoricalReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await client.get_historical(
|
||||
body.symbol, body.category, body.interval, body.start, body.end, body.limit,
|
||||
)
|
||||
|
||||
@app.post("/tools/get_indicators", tags=["reads"])
|
||||
async def t_get_indicators(body: IndicatorsReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await client.get_indicators(
|
||||
body.symbol, body.category, body.indicators,
|
||||
body.interval, body.start, body.end,
|
||||
)
|
||||
|
||||
@app.post("/tools/get_funding_rate", tags=["reads"])
|
||||
async def t_get_funding_rate(body: FundingRateReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await client.get_funding_rate(body.symbol, body.category)
|
||||
|
||||
@app.post("/tools/get_funding_history", tags=["reads"])
|
||||
async def t_get_funding_history(body: FundingHistoryReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await client.get_funding_history(body.symbol, body.category, body.limit)
|
||||
|
||||
@app.post("/tools/get_open_interest", tags=["reads"])
|
||||
async def t_get_open_interest(body: OpenInterestReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await client.get_open_interest(body.symbol, body.category, body.interval, body.limit)
|
||||
|
||||
@app.post("/tools/get_instruments", tags=["reads"])
|
||||
async def t_get_instruments(body: InstrumentsReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await client.get_instruments(body.category, body.symbol)
|
||||
|
||||
@app.post("/tools/get_option_chain", tags=["reads"])
|
||||
async def t_get_option_chain(body: OptionChainReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await client.get_option_chain(body.base_coin, body.expiry)
|
||||
|
||||
@app.post("/tools/get_positions", tags=["reads"])
|
||||
async def t_get_positions(body: PositionsReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return {"positions": await client.get_positions(body.category)}
|
||||
|
||||
@app.post("/tools/get_account_summary", tags=["reads"])
|
||||
async def t_get_account_summary(body: AccountSummaryReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await client.get_account_summary()
|
||||
|
||||
@app.post("/tools/get_trade_history", tags=["reads"])
|
||||
async def t_get_trade_history(body: TradeHistoryReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return {"trades": await client.get_trade_history(body.category, body.limit)}
|
||||
|
||||
@app.post("/tools/get_open_orders", tags=["reads"])
|
||||
async def t_get_open_orders(body: OpenOrdersReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return {"orders": await client.get_open_orders(body.category, body.symbol)}
|
||||
|
||||
@app.post("/tools/get_basis_spot_perp", tags=["reads"])
|
||||
async def t_get_basis_spot_perp(body: BasisSpotPerpReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True, observer=True)
|
||||
return await client.get_basis_spot_perp(body.asset)
|
||||
|
||||
# ── Writes ─────────────────────────────────────────────
|
||||
|
||||
@app.post("/tools/place_order", tags=["writes"])
|
||||
async def t_place_order(body: PlaceOrderReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True)
|
||||
return await client.place_order(
|
||||
body.category, body.symbol, body.side, body.qty,
|
||||
body.order_type, body.price, body.tif, body.reduce_only, body.position_idx,
|
||||
)
|
||||
|
||||
@app.post("/tools/amend_order", tags=["writes"])
|
||||
async def t_amend_order(body: AmendOrderReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True)
|
||||
return await client.amend_order(
|
||||
body.category, body.symbol, body.order_id, body.new_qty, body.new_price,
|
||||
)
|
||||
|
||||
@app.post("/tools/cancel_order", tags=["writes"])
|
||||
async def t_cancel_order(body: CancelOrderReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True)
|
||||
return await client.cancel_order(body.category, body.symbol, body.order_id)
|
||||
|
||||
@app.post("/tools/cancel_all_orders", tags=["writes"])
|
||||
async def t_cancel_all(body: CancelAllReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True)
|
||||
return await client.cancel_all_orders(body.category, body.symbol)
|
||||
|
||||
@app.post("/tools/set_stop_loss", tags=["writes"])
|
||||
async def t_set_sl(body: SetStopLossReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True)
|
||||
return await client.set_stop_loss(body.category, body.symbol, body.stop_loss, body.position_idx)
|
||||
|
||||
@app.post("/tools/set_take_profit", tags=["writes"])
|
||||
async def t_set_tp(body: SetTakeProfitReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True)
|
||||
return await client.set_take_profit(body.category, body.symbol, body.take_profit, body.position_idx)
|
||||
|
||||
@app.post("/tools/close_position", tags=["writes"])
|
||||
async def t_close(body: ClosePositionReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True)
|
||||
return await client.close_position(body.category, body.symbol)
|
||||
|
||||
@app.post("/tools/set_leverage", tags=["writes"])
|
||||
async def t_set_leverage(body: SetLeverageReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True)
|
||||
return await client.set_leverage(body.category, body.symbol, body.leverage)
|
||||
|
||||
@app.post("/tools/switch_position_mode", tags=["writes"])
|
||||
async def t_switch_mode(body: SwitchModeReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True)
|
||||
return await client.switch_position_mode(body.category, body.symbol, body.mode)
|
||||
|
||||
@app.post("/tools/transfer_asset", tags=["writes"])
|
||||
async def t_transfer(body: TransferReq, principal: Principal = Depends(require_principal)):
|
||||
_check(principal, core=True)
|
||||
return await client.transfer_asset(body.coin, body.amount, body.from_type, body.to_type)
|
||||
|
||||
# ── MCP mount ──────────────────────────────────────────
|
||||
|
||||
port = int(os.environ.get("PORT", "9019"))
|
||||
mount_mcp_endpoint(
|
||||
app,
|
||||
name="cerbero-bybit",
|
||||
version="0.1.0",
|
||||
token_store=token_store,
|
||||
internal_base_url=f"http://localhost:{port}",
|
||||
tools=[
|
||||
{"name": "get_ticker", "description": "Ticker Bybit (spot/linear/inverse/option)."},
|
||||
{"name": "get_ticker_batch", "description": "Ticker per più simboli."},
|
||||
{"name": "get_orderbook", "description": "Orderbook profondità N."},
|
||||
{"name": "get_historical", "description": "OHLCV candles Bybit."},
|
||||
{"name": "get_indicators", "description": "Indicatori tecnici (RSI, ATR, MACD, ADX)."},
|
||||
{"name": "get_funding_rate", "description": "Funding corrente perp."},
|
||||
{"name": "get_funding_history", "description": "Funding storico perp."},
|
||||
{"name": "get_open_interest", "description": "Open interest history perp."},
|
||||
{"name": "get_instruments", "description": "Specs contratti."},
|
||||
{"name": "get_option_chain", "description": "Option chain BTC/ETH/SOL."},
|
||||
{"name": "get_positions", "description": "Posizioni aperte."},
|
||||
{"name": "get_account_summary", "description": "Wallet balance e margine."},
|
||||
{"name": "get_trade_history", "description": "Fills recenti."},
|
||||
{"name": "get_open_orders", "description": "Ordini pending."},
|
||||
{"name": "get_basis_spot_perp", "description": "Basis spot vs linear perp."},
|
||||
{"name": "place_order", "description": "Invia ordine (CORE only)."},
|
||||
{"name": "amend_order", "description": "Modifica ordine esistente."},
|
||||
{"name": "cancel_order", "description": "Cancella ordine."},
|
||||
{"name": "cancel_all_orders", "description": "Cancella tutti ordini."},
|
||||
{"name": "set_stop_loss", "description": "Setta stop loss su posizione."},
|
||||
{"name": "set_take_profit", "description": "Setta take profit su posizione."},
|
||||
{"name": "close_position", "description": "Chiude posizione aperta."},
|
||||
{"name": "set_leverage", "description": "Leva buy+sell uniforme."},
|
||||
{"name": "switch_position_mode", "description": "Hedge vs one-way."},
|
||||
{"name": "transfer_asset", "description": "Trasferimento interno tra account types."},
|
||||
],
|
||||
)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_bybit.client import BybitClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http():
|
||||
return MagicMock(name="pybit_HTTP")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_http):
|
||||
return BybitClient(
|
||||
api_key="test_key",
|
||||
api_secret="test_secret",
|
||||
testnet=True,
|
||||
http=mock_http,
|
||||
)
|
||||
@@ -0,0 +1,531 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_bybit.client import BybitClient
|
||||
|
||||
|
||||
def test_client_init_stores_attrs(client, mock_http):
|
||||
assert client.testnet is True
|
||||
assert client._http is mock_http
|
||||
|
||||
|
||||
def test_client_init_default_http(monkeypatch):
|
||||
created = {}
|
||||
|
||||
class FakeHTTP:
|
||||
def __init__(self, **kwargs):
|
||||
created.update(kwargs)
|
||||
|
||||
monkeypatch.setattr("mcp_bybit.client.HTTP", FakeHTTP)
|
||||
BybitClient(api_key="k", api_secret="s", testnet=False)
|
||||
assert created["api_key"] == "k"
|
||||
assert created["api_secret"] == "s"
|
||||
assert created["testnet"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ticker(client, mock_http):
|
||||
mock_http.get_tickers.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {
|
||||
"list": [{
|
||||
"symbol": "BTCUSDT",
|
||||
"lastPrice": "60000",
|
||||
"markPrice": "60010",
|
||||
"bid1Price": "59995",
|
||||
"ask1Price": "60005",
|
||||
"volume24h": "1500.5",
|
||||
"turnover24h": "90000000",
|
||||
"fundingRate": "0.0001",
|
||||
"openInterest": "50000",
|
||||
}]
|
||||
},
|
||||
}
|
||||
t = await client.get_ticker("BTCUSDT", category="linear")
|
||||
mock_http.get_tickers.assert_called_once_with(category="linear", symbol="BTCUSDT")
|
||||
assert t["symbol"] == "BTCUSDT"
|
||||
assert t["last_price"] == 60000.0
|
||||
assert t["mark_price"] == 60010.0
|
||||
assert t["bid"] == 59995.0
|
||||
assert t["ask"] == 60005.0
|
||||
assert t["volume_24h"] == 1500.5
|
||||
assert t["funding_rate"] == 0.0001
|
||||
assert t["open_interest"] == 50000.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ticker_batch(client, mock_http):
|
||||
def side_effect(**kwargs):
|
||||
symbol = kwargs["symbol"]
|
||||
return {"retCode": 0, "result": {"list": [{
|
||||
"symbol": symbol, "lastPrice": "1", "markPrice": "1",
|
||||
"bid1Price": "1", "ask1Price": "1", "volume24h": "0",
|
||||
"turnover24h": "0", "fundingRate": "0", "openInterest": "0",
|
||||
}]}}
|
||||
mock_http.get_tickers.side_effect = side_effect
|
||||
out = await client.get_ticker_batch(["BTCUSDT", "ETHUSDT"], category="linear")
|
||||
assert set(out.keys()) == {"BTCUSDT", "ETHUSDT"}
|
||||
assert mock_http.get_tickers.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ticker_not_found(client, mock_http):
|
||||
mock_http.get_tickers.return_value = {"retCode": 0, "result": {"list": []}}
|
||||
t = await client.get_ticker("UNKNOWNUSDT", category="linear")
|
||||
assert t == {"symbol": "UNKNOWNUSDT", "error": "not_found"}
|
||||
|
||||
|
||||
def test_parse_helpers():
|
||||
from mcp_bybit.client import _f, _i
|
||||
assert _f("1.5") == 1.5
|
||||
assert _f("") is None
|
||||
assert _f(None) is None
|
||||
assert _i("42") == 42
|
||||
assert _i("") is None
|
||||
assert _i(None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_orderbook(client, mock_http):
|
||||
mock_http.get_orderbook.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {
|
||||
"s": "BTCUSDT",
|
||||
"b": [["59990", "0.5"], ["59980", "1.0"]],
|
||||
"a": [["60010", "0.3"], ["60020", "0.7"]],
|
||||
"ts": 1700000000000,
|
||||
},
|
||||
}
|
||||
ob = await client.get_orderbook("BTCUSDT", category="linear", limit=25)
|
||||
mock_http.get_orderbook.assert_called_once_with(
|
||||
category="linear", symbol="BTCUSDT", limit=25
|
||||
)
|
||||
assert ob["symbol"] == "BTCUSDT"
|
||||
assert ob["bids"] == [[59990.0, 0.5], [59980.0, 1.0]]
|
||||
assert ob["asks"] == [[60010.0, 0.3], [60020.0, 0.7]]
|
||||
assert ob["timestamp"] == 1700000000000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_historical(client, mock_http):
|
||||
mock_http.get_kline.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {
|
||||
"list": [
|
||||
["1700000000000", "60000", "60500", "59500", "60200", "100", "6020000"],
|
||||
["1700003600000", "60200", "60700", "60000", "60400", "80", "4832000"],
|
||||
]
|
||||
},
|
||||
}
|
||||
out = await client.get_historical(
|
||||
"BTCUSDT", category="linear", interval="60",
|
||||
start=1700000000000, end=1700003600000,
|
||||
)
|
||||
mock_http.get_kline.assert_called_once_with(
|
||||
category="linear", symbol="BTCUSDT", interval="60",
|
||||
start=1700000000000, end=1700003600000, limit=1000,
|
||||
)
|
||||
assert len(out["candles"]) == 2
|
||||
c0 = out["candles"][0]
|
||||
assert c0["timestamp"] == 1700000000000
|
||||
assert c0["open"] == 60000.0
|
||||
assert c0["high"] == 60500.0
|
||||
assert c0["low"] == 59500.0
|
||||
assert c0["close"] == 60200.0
|
||||
assert c0["volume"] == 100.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_indicators(client, mock_http):
|
||||
rows = [
|
||||
[str(1700000000000 + i * 3600_000),
|
||||
str(60000 + i * 10), str(60000 + i * 10 + 5),
|
||||
str(60000 + i * 10 - 5), str(60000 + i * 10 + 2),
|
||||
"100", "6000000"]
|
||||
for i in range(35)
|
||||
]
|
||||
mock_http.get_kline.return_value = {"retCode": 0, "result": {"list": rows}}
|
||||
out = await client.get_indicators(
|
||||
"BTCUSDT", category="linear",
|
||||
indicators=["rsi", "atr", "macd", "adx"],
|
||||
interval="60",
|
||||
)
|
||||
assert "rsi" in out and out["rsi"] is not None
|
||||
assert "atr" in out and out["atr"] is not None
|
||||
assert "macd" in out and out["macd"]["macd"] is not None
|
||||
assert "adx" in out and out["adx"]["adx"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_funding_rate(client, mock_http):
|
||||
mock_http.get_tickers.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {"list": [{
|
||||
"symbol": "BTCUSDT", "fundingRate": "0.0001",
|
||||
"nextFundingTime": "1700003600000",
|
||||
"lastPrice": "60000", "markPrice": "60000",
|
||||
"bid1Price": "0", "ask1Price": "0",
|
||||
"volume24h": "0", "turnover24h": "0", "openInterest": "0",
|
||||
}]},
|
||||
}
|
||||
out = await client.get_funding_rate("BTCUSDT", category="linear")
|
||||
assert out["symbol"] == "BTCUSDT"
|
||||
assert out["funding_rate"] == 0.0001
|
||||
assert out["next_funding_time"] == 1700003600000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_funding_history(client, mock_http):
|
||||
mock_http.get_funding_rate_history.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {"list": [
|
||||
{"symbol": "BTCUSDT", "fundingRate": "0.0001", "fundingRateTimestamp": "1700000000000"},
|
||||
{"symbol": "BTCUSDT", "fundingRate": "0.00008", "fundingRateTimestamp": "1699996400000"},
|
||||
]},
|
||||
}
|
||||
out = await client.get_funding_history("BTCUSDT", category="linear", limit=50)
|
||||
mock_http.get_funding_rate_history.assert_called_once_with(
|
||||
category="linear", symbol="BTCUSDT", limit=50
|
||||
)
|
||||
assert len(out["history"]) == 2
|
||||
assert out["history"][0]["rate"] == 0.0001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_open_interest(client, mock_http):
|
||||
mock_http.get_open_interest.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {"list": [
|
||||
{"openInterest": "50000", "timestamp": "1700000000000"},
|
||||
{"openInterest": "49000", "timestamp": "1699996400000"},
|
||||
]},
|
||||
}
|
||||
out = await client.get_open_interest("BTCUSDT", category="linear", interval="5min", limit=100)
|
||||
mock_http.get_open_interest.assert_called_once_with(
|
||||
category="linear", symbol="BTCUSDT", intervalTime="5min", limit=100
|
||||
)
|
||||
assert len(out["points"]) == 2
|
||||
assert out["current_oi"] == 50000.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instruments(client, mock_http):
|
||||
mock_http.get_instruments_info.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {"list": [
|
||||
{"symbol": "BTCUSDT", "status": "Trading", "baseCoin": "BTC",
|
||||
"quoteCoin": "USDT", "priceFilter": {"tickSize": "0.1"},
|
||||
"lotSizeFilter": {"qtyStep": "0.001", "minOrderQty": "0.001"}},
|
||||
]},
|
||||
}
|
||||
out = await client.get_instruments(category="linear")
|
||||
mock_http.get_instruments_info.assert_called_once_with(category="linear")
|
||||
assert len(out["instruments"]) == 1
|
||||
inst = out["instruments"][0]
|
||||
assert inst["symbol"] == "BTCUSDT"
|
||||
assert inst["tick_size"] == 0.1
|
||||
assert inst["qty_step"] == 0.001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_option_chain(client, mock_http):
|
||||
mock_http.get_instruments_info.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {"list": [
|
||||
{"symbol": "BTC-30JUN25-50000-C", "baseCoin": "BTC",
|
||||
"settleCoin": "USDC", "optionsType": "Call",
|
||||
"launchTime": "1700000000000", "deliveryTime": "1719734400000"},
|
||||
{"symbol": "BTC-30JUN25-50000-P", "baseCoin": "BTC",
|
||||
"settleCoin": "USDC", "optionsType": "Put",
|
||||
"launchTime": "1700000000000", "deliveryTime": "1719734400000"},
|
||||
]},
|
||||
}
|
||||
out = await client.get_option_chain(base_coin="BTC")
|
||||
mock_http.get_instruments_info.assert_called_once_with(category="option", baseCoin="BTC")
|
||||
assert len(out["options"]) == 2
|
||||
assert out["options"][0]["type"] == "Call"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_positions(client, mock_http):
|
||||
mock_http.get_positions.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {"list": [
|
||||
{"symbol": "BTCUSDT", "side": "Buy", "size": "0.1",
|
||||
"avgPrice": "60000", "unrealisedPnl": "50",
|
||||
"leverage": "10", "liqPrice": "50000", "positionValue": "6000"},
|
||||
]},
|
||||
}
|
||||
out = await client.get_positions(category="linear")
|
||||
mock_http.get_positions.assert_called_once_with(category="linear", settleCoin="USDT")
|
||||
assert len(out) == 1
|
||||
p = out[0]
|
||||
assert p["symbol"] == "BTCUSDT"
|
||||
assert p["side"] == "Buy"
|
||||
assert p["size"] == 0.1
|
||||
assert p["entry_price"] == 60000.0
|
||||
assert p["liquidation_price"] == 50000.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_summary(client, mock_http):
|
||||
mock_http.get_wallet_balance.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {"list": [{
|
||||
"accountType": "UNIFIED",
|
||||
"totalEquity": "10000",
|
||||
"totalWalletBalance": "9500",
|
||||
"totalMarginBalance": "9800",
|
||||
"totalAvailableBalance": "9000",
|
||||
"totalPerpUPL": "200",
|
||||
"coin": [
|
||||
{"coin": "USDT", "walletBalance": "9500", "equity": "9700"}
|
||||
],
|
||||
}]},
|
||||
}
|
||||
out = await client.get_account_summary()
|
||||
mock_http.get_wallet_balance.assert_called_once_with(accountType="UNIFIED")
|
||||
assert out["equity"] == 10000.0
|
||||
assert out["available_balance"] == 9000.0
|
||||
assert out["unrealized_pnl"] == 200.0
|
||||
assert len(out["coins"]) == 1
|
||||
assert out["coins"][0]["coin"] == "USDT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_trade_history(client, mock_http):
|
||||
mock_http.get_executions.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {"list": [
|
||||
{"symbol": "BTCUSDT", "side": "Buy", "execQty": "0.01",
|
||||
"execPrice": "60000", "execFee": "0.1",
|
||||
"execTime": "1700000000000", "orderId": "abc"},
|
||||
]},
|
||||
}
|
||||
out = await client.get_trade_history(category="linear", limit=50)
|
||||
mock_http.get_executions.assert_called_once_with(category="linear", limit=50)
|
||||
assert len(out) == 1
|
||||
assert out[0]["symbol"] == "BTCUSDT"
|
||||
assert out[0]["size"] == 0.01
|
||||
assert out[0]["price"] == 60000.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_open_orders(client, mock_http):
|
||||
mock_http.get_open_orders.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {"list": [
|
||||
{"symbol": "BTCUSDT", "orderId": "o1", "side": "Buy",
|
||||
"qty": "0.1", "price": "59000", "orderType": "Limit",
|
||||
"orderStatus": "New", "reduceOnly": False},
|
||||
]},
|
||||
}
|
||||
out = await client.get_open_orders(category="linear")
|
||||
mock_http.get_open_orders.assert_called_once_with(category="linear", settleCoin="USDT")
|
||||
assert len(out) == 1
|
||||
assert out[0]["order_id"] == "o1"
|
||||
assert out[0]["price"] == 59000.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_basis_spot_perp(client, mock_http):
|
||||
def side(**kwargs):
|
||||
if kwargs["category"] == "spot":
|
||||
return {"retCode": 0, "result": {"list": [{
|
||||
"symbol": "BTCUSDT", "lastPrice": "60000", "markPrice": "60000",
|
||||
"bid1Price": "59995", "ask1Price": "60005",
|
||||
"volume24h": "0", "turnover24h": "0",
|
||||
"fundingRate": "0", "openInterest": "0",
|
||||
}]}}
|
||||
else:
|
||||
return {"retCode": 0, "result": {"list": [{
|
||||
"symbol": "BTCUSDT", "lastPrice": "60120", "markPrice": "60120",
|
||||
"bid1Price": "60115", "ask1Price": "60125",
|
||||
"volume24h": "0", "turnover24h": "0",
|
||||
"fundingRate": "0.0001", "openInterest": "0",
|
||||
}]}}
|
||||
mock_http.get_tickers.side_effect = side
|
||||
out = await client.get_basis_spot_perp("BTC")
|
||||
assert out["asset"] == "BTC"
|
||||
assert out["spot_price"] == 60000.0
|
||||
assert out["perp_price"] == 60120.0
|
||||
assert out["basis_abs"] == 120.0
|
||||
assert round(out["basis_pct"], 3) == 0.2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_order_limit(client, mock_http):
|
||||
mock_http.place_order.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {"orderId": "ord123", "orderLinkId": ""},
|
||||
}
|
||||
out = await client.place_order(
|
||||
category="linear", symbol="BTCUSDT", side="Buy",
|
||||
qty=0.01, order_type="Limit", price=60000.0, tif="GTC",
|
||||
)
|
||||
assert out["order_id"] == "ord123"
|
||||
kwargs = mock_http.place_order.call_args.kwargs
|
||||
assert kwargs["category"] == "linear"
|
||||
assert kwargs["symbol"] == "BTCUSDT"
|
||||
assert kwargs["side"] == "Buy"
|
||||
assert kwargs["qty"] == "0.01"
|
||||
assert kwargs["orderType"] == "Limit"
|
||||
assert kwargs["price"] == "60000.0"
|
||||
assert kwargs["timeInForce"] == "GTC"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_order_error(client, mock_http):
|
||||
mock_http.place_order.return_value = {"retCode": 10001, "retMsg": "insufficient balance"}
|
||||
out = await client.place_order(
|
||||
category="linear", symbol="BTCUSDT", side="Buy", qty=0.01, order_type="Market"
|
||||
)
|
||||
assert out.get("error") == "insufficient balance"
|
||||
assert out.get("code") == 10001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_amend_order(client, mock_http):
|
||||
mock_http.amend_order.return_value = {"retCode": 0, "result": {"orderId": "ord1"}}
|
||||
out = await client.amend_order(
|
||||
category="linear", symbol="BTCUSDT", order_id="ord1", new_qty=0.02
|
||||
)
|
||||
assert out["order_id"] == "ord1"
|
||||
kwargs = mock_http.amend_order.call_args.kwargs
|
||||
assert kwargs["orderId"] == "ord1"
|
||||
assert kwargs["qty"] == "0.02"
|
||||
assert "price" not in kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_order_option_adds_link_id(client, mock_http):
|
||||
mock_http.place_order.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {"orderId": "opt1", "orderLinkId": "cerbero-abc"},
|
||||
}
|
||||
await client.place_order(
|
||||
category="option", symbol="BTC-24APR26-96000-C-USDT",
|
||||
side="Buy", qty=0.01, order_type="Limit", price=5.0,
|
||||
)
|
||||
kwargs = mock_http.place_order.call_args.kwargs
|
||||
assert "orderLinkId" in kwargs
|
||||
assert kwargs["orderLinkId"].startswith("cerbero-")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_place_order_linear_no_link_id(client, mock_http):
|
||||
mock_http.place_order.return_value = {"retCode": 0, "result": {"orderId": "x"}}
|
||||
await client.place_order(
|
||||
category="linear", symbol="BTCUSDT", side="Buy", qty=0.01, order_type="Market"
|
||||
)
|
||||
kwargs = mock_http.place_order.call_args.kwargs
|
||||
assert "orderLinkId" not in kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_order(client, mock_http):
|
||||
mock_http.cancel_order.return_value = {"retCode": 0, "result": {"orderId": "ord1"}}
|
||||
out = await client.cancel_order(category="linear", symbol="BTCUSDT", order_id="ord1")
|
||||
mock_http.cancel_order.assert_called_once_with(
|
||||
category="linear", symbol="BTCUSDT", orderId="ord1"
|
||||
)
|
||||
assert out["order_id"] == "ord1"
|
||||
assert out["status"] == "cancelled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_all_orders(client, mock_http):
|
||||
mock_http.cancel_all_orders.return_value = {
|
||||
"retCode": 0,
|
||||
"result": {"list": [{"orderId": "o1"}, {"orderId": "o2"}]},
|
||||
}
|
||||
out = await client.cancel_all_orders(category="linear", symbol="BTCUSDT")
|
||||
mock_http.cancel_all_orders.assert_called_once_with(
|
||||
category="linear", symbol="BTCUSDT"
|
||||
)
|
||||
assert out["cancelled_ids"] == ["o1", "o2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_stop_loss(client, mock_http):
|
||||
mock_http.set_trading_stop.return_value = {"retCode": 0, "result": {}}
|
||||
out = await client.set_stop_loss(
|
||||
category="linear", symbol="BTCUSDT", stop_loss=55000.0
|
||||
)
|
||||
mock_http.set_trading_stop.assert_called_once()
|
||||
kwargs = mock_http.set_trading_stop.call_args.kwargs
|
||||
assert kwargs["category"] == "linear"
|
||||
assert kwargs["symbol"] == "BTCUSDT"
|
||||
assert kwargs["stopLoss"] == "55000.0"
|
||||
assert kwargs.get("positionIdx", 0) == 0
|
||||
assert out["status"] == "stop_loss_set"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_take_profit(client, mock_http):
|
||||
mock_http.set_trading_stop.return_value = {"retCode": 0, "result": {}}
|
||||
out = await client.set_take_profit(
|
||||
category="linear", symbol="BTCUSDT", take_profit=65000.0
|
||||
)
|
||||
kwargs = mock_http.set_trading_stop.call_args.kwargs
|
||||
assert kwargs["takeProfit"] == "65000.0"
|
||||
assert out["status"] == "take_profit_set"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_position(client, mock_http):
|
||||
mock_http.get_positions.return_value = {
|
||||
"retCode": 0, "result": {"list": [
|
||||
{"symbol": "BTCUSDT", "side": "Buy", "size": "0.1",
|
||||
"avgPrice": "60000", "unrealisedPnl": "0",
|
||||
"leverage": "10", "liqPrice": "0", "positionValue": "6000"},
|
||||
]},
|
||||
}
|
||||
mock_http.place_order.return_value = {
|
||||
"retCode": 0, "result": {"orderId": "closeord", "orderLinkId": ""},
|
||||
}
|
||||
out = await client.close_position(category="linear", symbol="BTCUSDT")
|
||||
assert out["status"] == "submitted"
|
||||
kwargs = mock_http.place_order.call_args.kwargs
|
||||
assert kwargs["side"] == "Sell"
|
||||
assert kwargs["qty"] == "0.1"
|
||||
assert kwargs["reduceOnly"] is True
|
||||
assert kwargs["orderType"] == "Market"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_leverage(client, mock_http):
|
||||
mock_http.set_leverage.return_value = {"retCode": 0, "result": {}}
|
||||
out = await client.set_leverage(category="linear", symbol="BTCUSDT", leverage=5)
|
||||
mock_http.set_leverage.assert_called_once_with(
|
||||
category="linear", symbol="BTCUSDT", buyLeverage="5", sellLeverage="5"
|
||||
)
|
||||
assert out["status"] == "leverage_set"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_position_mode(client, mock_http):
|
||||
mock_http.switch_position_mode.return_value = {"retCode": 0, "result": {}}
|
||||
out = await client.switch_position_mode(
|
||||
category="linear", symbol="BTCUSDT", mode="hedge"
|
||||
)
|
||||
kwargs = mock_http.switch_position_mode.call_args.kwargs
|
||||
assert kwargs["mode"] == 3
|
||||
assert out["status"] == "mode_switched"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transfer_asset(client, mock_http):
|
||||
mock_http.create_internal_transfer.return_value = {
|
||||
"retCode": 0, "result": {"transferId": "tx123"},
|
||||
}
|
||||
out = await client.transfer_asset(
|
||||
coin="USDT", amount=100.0, from_type="UNIFIED", to_type="FUND"
|
||||
)
|
||||
kwargs = mock_http.create_internal_transfer.call_args.kwargs
|
||||
assert kwargs["coin"] == "USDT"
|
||||
assert kwargs["amount"] == "100.0"
|
||||
assert kwargs["fromAccountType"] == "UNIFIED"
|
||||
assert kwargs["toAccountType"] == "FUND"
|
||||
assert out["transfer_id"] == "tx123"
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from option_mcp_common.auth import Principal, TokenStore
|
||||
|
||||
from mcp_bybit.server import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_store():
|
||||
return TokenStore(
|
||||
tokens={
|
||||
"core-tok": Principal("core", {"core"}),
|
||||
"obs-tok": Principal("observer", {"observer"}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
c = MagicMock()
|
||||
c.get_ticker = AsyncMock(return_value={"symbol": "BTCUSDT"})
|
||||
c.get_ticker_batch = AsyncMock(return_value={"BTCUSDT": {}})
|
||||
c.get_orderbook = AsyncMock(return_value={"bids": [], "asks": []})
|
||||
c.get_historical = AsyncMock(return_value={"candles": []})
|
||||
c.get_indicators = AsyncMock(return_value={"rsi": 50.0})
|
||||
c.get_funding_rate = AsyncMock(return_value={"funding_rate": 0.0001})
|
||||
c.get_funding_history = AsyncMock(return_value={"history": []})
|
||||
c.get_open_interest = AsyncMock(return_value={"points": []})
|
||||
c.get_instruments = AsyncMock(return_value={"instruments": []})
|
||||
c.get_option_chain = AsyncMock(return_value={"options": []})
|
||||
c.get_positions = AsyncMock(return_value=[])
|
||||
c.get_account_summary = AsyncMock(return_value={"equity": 0})
|
||||
c.get_trade_history = AsyncMock(return_value=[])
|
||||
c.get_open_orders = AsyncMock(return_value=[])
|
||||
c.get_basis_spot_perp = AsyncMock(return_value={"basis_pct": 0})
|
||||
c.place_order = AsyncMock(return_value={"order_id": "x"})
|
||||
c.amend_order = AsyncMock(return_value={"order_id": "x"})
|
||||
c.cancel_order = AsyncMock(return_value={"status": "cancelled"})
|
||||
c.cancel_all_orders = AsyncMock(return_value={"cancelled_ids": []})
|
||||
c.set_stop_loss = AsyncMock(return_value={"status": "stop_loss_set"})
|
||||
c.set_take_profit = AsyncMock(return_value={"status": "take_profit_set"})
|
||||
c.close_position = AsyncMock(return_value={"status": "submitted"})
|
||||
c.set_leverage = AsyncMock(return_value={"status": "leverage_set"})
|
||||
c.switch_position_mode = AsyncMock(return_value={"status": "mode_switched"})
|
||||
c.transfer_asset = AsyncMock(return_value={"transfer_id": "tx"})
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http(mock_client, token_store):
|
||||
app = create_app(client=mock_client, token_store=token_store)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
CORE = {"Authorization": "Bearer core-tok"}
|
||||
OBS = {"Authorization": "Bearer obs-tok"}
|
||||
|
||||
READ_ENDPOINTS = [
|
||||
("/tools/get_ticker", {"symbol": "BTCUSDT"}),
|
||||
("/tools/get_ticker_batch", {"symbols": ["BTCUSDT"]}),
|
||||
("/tools/get_orderbook", {"symbol": "BTCUSDT"}),
|
||||
("/tools/get_historical", {"symbol": "BTCUSDT"}),
|
||||
("/tools/get_indicators", {"symbol": "BTCUSDT"}),
|
||||
("/tools/get_funding_rate", {"symbol": "BTCUSDT"}),
|
||||
("/tools/get_funding_history", {"symbol": "BTCUSDT"}),
|
||||
("/tools/get_open_interest", {"symbol": "BTCUSDT"}),
|
||||
("/tools/get_instruments", {}),
|
||||
("/tools/get_option_chain", {"base_coin": "BTC"}),
|
||||
("/tools/get_positions", {}),
|
||||
("/tools/get_account_summary", {}),
|
||||
("/tools/get_trade_history", {}),
|
||||
("/tools/get_open_orders", {}),
|
||||
("/tools/get_basis_spot_perp", {"asset": "BTC"}),
|
||||
]
|
||||
|
||||
WRITE_ENDPOINTS = [
|
||||
("/tools/place_order", {"category": "linear", "symbol": "BTCUSDT", "side": "Buy", "qty": 0.01}),
|
||||
("/tools/amend_order", {"category": "linear", "symbol": "BTCUSDT", "order_id": "o1"}),
|
||||
("/tools/cancel_order", {"category": "linear", "symbol": "BTCUSDT", "order_id": "o1"}),
|
||||
("/tools/cancel_all_orders", {"category": "linear"}),
|
||||
("/tools/set_stop_loss", {"category": "linear", "symbol": "BTCUSDT", "stop_loss": 55000}),
|
||||
("/tools/set_take_profit", {"category": "linear", "symbol": "BTCUSDT", "take_profit": 65000}),
|
||||
("/tools/close_position", {"category": "linear", "symbol": "BTCUSDT"}),
|
||||
("/tools/set_leverage", {"category": "linear", "symbol": "BTCUSDT", "leverage": 5}),
|
||||
("/tools/switch_position_mode", {"category": "linear", "symbol": "BTCUSDT", "mode": "hedge"}),
|
||||
("/tools/transfer_asset", {"coin": "USDT", "amount": 10.0, "from_type": "UNIFIED", "to_type": "FUND"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,payload", READ_ENDPOINTS)
|
||||
def test_read_core_ok(http, path, payload):
|
||||
r = http.post(path, json=payload, headers=CORE)
|
||||
assert r.status_code == 200, (path, r.text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,payload", READ_ENDPOINTS)
|
||||
def test_read_observer_ok(http, path, payload):
|
||||
r = http.post(path, json=payload, headers=OBS)
|
||||
assert r.status_code == 200, (path, r.text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,payload", READ_ENDPOINTS)
|
||||
def test_read_no_auth_401(http, path, payload):
|
||||
r = http.post(path, json=payload)
|
||||
assert r.status_code == 401, (path, r.text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,payload", WRITE_ENDPOINTS)
|
||||
def test_write_core_ok(http, path, payload):
|
||||
r = http.post(path, json=payload, headers=CORE)
|
||||
assert r.status_code == 200, (path, r.text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,payload", WRITE_ENDPOINTS)
|
||||
def test_write_observer_403(http, path, payload):
|
||||
r = http.post(path, json=payload, headers=OBS)
|
||||
assert r.status_code == 403, (path, r.text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,payload", WRITE_ENDPOINTS)
|
||||
def test_write_no_auth_401(http, path, payload):
|
||||
r = http.post(path, json=payload)
|
||||
assert r.status_code == 401, (path, r.text)
|
||||
Reference in New Issue
Block a user