559 lines
19 KiB
Python
559 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
|
|
from 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",
|
|
})
|