Initial commit: Trading Portal - FastAPI + React + PostgreSQL
This commit is contained in:
Executable
Executable
+344
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Optional
|
||||
|
||||
import ccxt
|
||||
|
||||
from app.exchange.rate_limiter import GlobalRateLimiter, RateLimiter
|
||||
from app.exchange.types import (
|
||||
BalanceData,
|
||||
BalanceResponse,
|
||||
CandleData,
|
||||
CandleValidationError,
|
||||
OpenOrderData,
|
||||
OrderData,
|
||||
OrderRequest,
|
||||
PositionData,
|
||||
SymbolInfo,
|
||||
TickerData,
|
||||
TradeData,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AbstractExchange(ABC):
|
||||
"""Abstract base class for exchange adapters."""
|
||||
|
||||
def __init__(self, api_key: str = "", api_secret: str = "", testnet: bool = False) -> None:
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret
|
||||
self.testnet = testnet
|
||||
self._client: Optional[ccxt.Exchange] = None
|
||||
self._rate_limiter: Optional[RateLimiter] = None
|
||||
|
||||
def _init_ccxt(self) -> ccxt.Exchange:
|
||||
"""Initialize and return a CCXT exchange client.
|
||||
|
||||
Subclasses must override this to configure the specific exchange.
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement _init_ccxt")
|
||||
|
||||
@property
|
||||
def client(self) -> ccxt.Exchange:
|
||||
if self._client is None:
|
||||
self._client = self._init_ccxt()
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def rate_limiter(self) -> RateLimiter:
|
||||
if self._rate_limiter is None:
|
||||
self._rate_limiter = GlobalRateLimiter(self.get_name())
|
||||
return self._rate_limiter
|
||||
|
||||
@abstractmethod
|
||||
def get_name(self) -> str:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_base_url(self) -> str:
|
||||
"""Return the REST API base URL."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_ws_url(self) -> str:
|
||||
"""Return the WebSocket URL."""
|
||||
...
|
||||
|
||||
async def fetch_ohlcv(self, symbol: str, timeframe: str = "1h", limit: int = 500) -> list[CandleData]:
|
||||
"""Fetch OHLCV candle data from the exchange."""
|
||||
await self.rate_limiter.acquire()
|
||||
raw = await self._async_fetch_ohlcv(symbol, timeframe, limit)
|
||||
|
||||
candles: list[CandleData] = []
|
||||
for entry in raw:
|
||||
ts, o, h, l, c, v = entry
|
||||
open_dec = Decimal(str(o))
|
||||
high_dec = Decimal(str(h))
|
||||
low_dec = Decimal(str(l))
|
||||
close_dec = Decimal(str(c))
|
||||
volume_dec = Decimal(str(v))
|
||||
|
||||
if not (low_dec <= open_dec <= high_dec and low_dec <= close_dec <= high_dec):
|
||||
continue
|
||||
|
||||
candles.append(
|
||||
CandleData(
|
||||
symbol=symbol,
|
||||
exchange=self.get_name(),
|
||||
timeframe=timeframe,
|
||||
timestamp=datetime.fromtimestamp(ts / 1000, tz=timezone.utc),
|
||||
open=open_dec,
|
||||
high=high_dec,
|
||||
low=low_dec,
|
||||
close=close_dec,
|
||||
volume=volume_dec,
|
||||
)
|
||||
)
|
||||
return candles
|
||||
|
||||
async def _async_fetch_ohlcv(self, symbol: str, timeframe: str, limit: int, since: int | None = None) -> list[list[Any]]:
|
||||
"""Run the synchronous CCXT fetch_ohlcv in a thread pool."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
kwargs = dict(symbol=symbol, timeframe=timeframe, limit=limit)
|
||||
if since is not None:
|
||||
kwargs["since"] = since
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.client.fetch_ohlcv(**kwargs),
|
||||
)
|
||||
|
||||
async def fetch_ticker(self, symbol: str) -> TickerData:
|
||||
"""Fetch ticker data from the exchange."""
|
||||
await self.rate_limiter.acquire()
|
||||
raw = await self._async_fetch_ticker(symbol)
|
||||
|
||||
return TickerData(
|
||||
symbol=symbol,
|
||||
exchange=self.get_name(),
|
||||
bid=Decimal(str(raw.get("bid", 0))),
|
||||
ask=Decimal(str(raw.get("ask", 0))),
|
||||
last=Decimal(str(raw.get("last", 0))),
|
||||
volume_24h=Decimal(str(raw.get("baseVolume", 0))),
|
||||
change_24h=Decimal(str(raw.get("percentage", 0))) if raw.get("percentage") is not None else None,
|
||||
timestamp=datetime.fromtimestamp(raw["timestamp"] / 1000, tz=timezone.utc) if raw.get("timestamp") else datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
async def _async_fetch_ticker(self, symbol: str) -> dict[str, Any]:
|
||||
"""Run the synchronous CCXT fetch_ticker in a thread pool."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.client.fetch_ticker(symbol),
|
||||
)
|
||||
|
||||
async def fetch_symbols(self) -> list[SymbolInfo]:
|
||||
"""Fetch all available trading pairs from the exchange."""
|
||||
await self.rate_limiter.acquire()
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
markets = await loop.run_in_executor(None, lambda: self.client.load_markets())
|
||||
|
||||
result: list[SymbolInfo] = []
|
||||
for sym, info in markets.items():
|
||||
if info.get("active", True):
|
||||
result.append(
|
||||
SymbolInfo(
|
||||
symbol=sym,
|
||||
base=info.get("base", ""),
|
||||
quote=info.get("quote", ""),
|
||||
exchange=self.get_name(),
|
||||
is_active=info.get("active", True),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Order placement
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def create_order(self, req: OrderRequest) -> OrderData:
|
||||
"""Place an order on the exchange.
|
||||
|
||||
Subclasses may override to add exchange-specific logic (e.g. leverage,
|
||||
position side). Default implementation uses CCXT's create_order.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
await self.rate_limiter.acquire()
|
||||
params: dict[str, Any] = {}
|
||||
if req.reduce_only:
|
||||
params["reduceOnly"] = True
|
||||
if req.position_side:
|
||||
params["positionSide"] = req.position_side.upper()
|
||||
|
||||
def _place() -> dict[str, Any]:
|
||||
return self.client.create_order(
|
||||
symbol=req.symbol,
|
||||
type=req.order_type,
|
||||
side=req.side,
|
||||
amount=float(req.amount),
|
||||
price=float(req.price) if req.price else None,
|
||||
params=params,
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
raw = await loop.run_in_executor(None, _place)
|
||||
|
||||
return OrderData(
|
||||
exchange=self.get_name(),
|
||||
symbol=raw.get("symbol", req.symbol),
|
||||
order_id=str(raw.get("id", "")),
|
||||
client_order_id=raw.get("clientOrderId"),
|
||||
side=raw.get("side", req.side),
|
||||
order_type=raw.get("type", req.order_type),
|
||||
amount=Decimal(str(raw.get("amount", float(req.amount)))),
|
||||
filled=Decimal(str(raw.get("filled", 0))),
|
||||
price=Decimal(str(raw["price"])) if raw.get("price") else req.price,
|
||||
average=Decimal(str(raw["average"])) if raw.get("average") else None,
|
||||
status=raw.get("status", "open"),
|
||||
timestamp=datetime.fromtimestamp(raw["timestamp"] / 1000, tz=timezone.utc) if raw.get("timestamp") else datetime.now(tz=timezone.utc),
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Balance
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def fetch_balance(self) -> BalanceResponse:
|
||||
"""Fetch the full account balance from the exchange."""
|
||||
import asyncio
|
||||
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
def _fetch() -> dict[str, Any]:
|
||||
return self.client.fetch_balance()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
raw = await loop.run_in_executor(None, _fetch)
|
||||
|
||||
balances: list[BalanceData] = []
|
||||
for asset, info in raw.get("total", {}).items():
|
||||
if asset == "info" or asset == "free" or asset == "used" or asset == "total":
|
||||
continue
|
||||
free = Decimal(str(raw.get("free", {}).get(asset, 0)))
|
||||
used = Decimal(str(raw.get("used", {}).get(asset, 0)))
|
||||
total = Decimal(str(info))
|
||||
if total > 0 or free > 0:
|
||||
balances.append(BalanceData(asset=asset, free=free, used=used, total=total))
|
||||
|
||||
return BalanceResponse(
|
||||
exchange=self.get_name(),
|
||||
balances=balances,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Open Orders, Trades, Positions
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def fetch_open_orders(self, symbol: str | None = None) -> list[OpenOrderData]:
|
||||
"""Fetch open orders from the exchange."""
|
||||
import asyncio
|
||||
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
def _fetch() -> list[dict[str, Any]]:
|
||||
return self.client.fetch_open_orders(symbol=symbol)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
raw_orders = await loop.run_in_executor(None, _fetch)
|
||||
|
||||
orders: list[OpenOrderData] = []
|
||||
for raw in (raw_orders or []):
|
||||
orders.append(OpenOrderData(
|
||||
order_id=str(raw.get("id", "")),
|
||||
symbol=raw.get("symbol", ""),
|
||||
side=raw.get("side", ""),
|
||||
order_type=raw.get("type", ""),
|
||||
amount=Decimal(str(raw.get("amount", 0))),
|
||||
filled=Decimal(str(raw.get("filled", 0))),
|
||||
price=Decimal(str(raw["price"])) if raw.get("price") else None,
|
||||
average=Decimal(str(raw["average"])) if raw.get("average") else None,
|
||||
status=raw.get("status", "open"),
|
||||
timestamp=datetime.fromtimestamp(raw["timestamp"] / 1000, tz=timezone.utc)
|
||||
if raw.get("timestamp") else None,
|
||||
))
|
||||
return orders
|
||||
|
||||
async def fetch_my_trades(self, symbol: str | None = None, limit: int = 10) -> list[TradeData]:
|
||||
"""Fetch recent filled trades from the exchange."""
|
||||
import asyncio
|
||||
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
def _fetch() -> list[dict[str, Any]]:
|
||||
return self.client.fetch_my_trades(symbol=symbol, limit=limit)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
raw_trades = await loop.run_in_executor(None, _fetch)
|
||||
|
||||
trades: list[TradeData] = []
|
||||
for raw in (raw_trades or []):
|
||||
cost = None
|
||||
if raw.get("cost"):
|
||||
cost = Decimal(str(raw["cost"]))
|
||||
fee_val = None
|
||||
fee_currency = None
|
||||
if raw.get("fee"):
|
||||
fee_val = Decimal(str(raw["fee"].get("cost", 0)))
|
||||
fee_currency = raw["fee"].get("currency")
|
||||
trades.append(TradeData(
|
||||
trade_id=str(raw.get("id", "")),
|
||||
symbol=raw.get("symbol", ""),
|
||||
side=raw.get("side", ""),
|
||||
amount=Decimal(str(raw.get("amount", 0))),
|
||||
price=Decimal(str(raw.get("price", 0))),
|
||||
cost=cost,
|
||||
fee=fee_val,
|
||||
fee_currency=fee_currency,
|
||||
timestamp=datetime.fromtimestamp(raw["timestamp"] / 1000, tz=timezone.utc)
|
||||
if raw.get("timestamp") else None,
|
||||
))
|
||||
return trades
|
||||
|
||||
async def fetch_positions(self, symbols: list[str] | None = None) -> list[PositionData]:
|
||||
"""Fetch open positions (futures/derivatives) from the exchange."""
|
||||
import asyncio
|
||||
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
def _fetch() -> list[dict[str, Any]]:
|
||||
return self.client.fetch_positions(symbols=symbols)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
raw_positions = await loop.run_in_executor(None, _fetch)
|
||||
|
||||
positions: list[PositionData] = []
|
||||
for raw in (raw_positions or []):
|
||||
contracts = Decimal(str(raw.get("contracts", 0)))
|
||||
if contracts == 0:
|
||||
continue
|
||||
positions.append(PositionData(
|
||||
symbol=raw.get("symbol", ""),
|
||||
side=raw.get("side", ""),
|
||||
contracts=contracts,
|
||||
entry_price=Decimal(str(raw["entryPrice"])) if raw.get("entryPrice") else None,
|
||||
mark_price=Decimal(str(raw["markPrice"])) if raw.get("markPrice") else None,
|
||||
unrealized_pnl=Decimal(str(raw["unrealizedPnl"])) if raw.get("unrealizedPnl") else None,
|
||||
leverage=Decimal(str(raw["leverage"])) if raw.get("leverage") else None,
|
||||
liquidation_price=Decimal(str(raw["liquidationPrice"])) if raw.get("liquidationPrice") else None,
|
||||
percentage=Decimal(str(raw["percentage"])) if raw.get("percentage") else None,
|
||||
))
|
||||
return positions
|
||||
|
||||
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, timezone
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ccxt
|
||||
|
||||
from app.exchange.base import AbstractExchange
|
||||
|
||||
|
||||
class BinanceAdapter(AbstractExchange):
|
||||
"""Exchange adapter for Binance."""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "binance"
|
||||
|
||||
def get_base_url(self) -> str:
|
||||
return "https://api.binance.com"
|
||||
|
||||
def get_ws_url(self) -> str:
|
||||
return "wss://stream.binance.com:9443/ws"
|
||||
|
||||
def _init_ccxt(self) -> ccxt.Exchange:
|
||||
options: dict = {
|
||||
"apiKey": self.api_key,
|
||||
"secret": self.api_secret,
|
||||
"rateLimit": 1200,
|
||||
"enableRateLimit": True,
|
||||
"options": {
|
||||
"warnOnFetchOpenOrdersWithoutSymbol": False,
|
||||
},
|
||||
}
|
||||
if self.testnet:
|
||||
options["urls"] = {
|
||||
"api": {
|
||||
"public": "https://testnet.binance.vision/api",
|
||||
"private": "https://testnet.binance.vision/api",
|
||||
}
|
||||
}
|
||||
return ccxt.binance(options)
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ccxt
|
||||
|
||||
from app.exchange.base import AbstractExchange
|
||||
|
||||
|
||||
class BingXAdapter(AbstractExchange):
|
||||
"""Exchange adapter for BingX."""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "bingx"
|
||||
|
||||
def get_base_url(self) -> str:
|
||||
return "https://api.bingx.com"
|
||||
|
||||
def get_ws_url(self) -> str:
|
||||
return "wss://open-api-ws.bingx.com/market"
|
||||
|
||||
def _init_ccxt(self) -> ccxt.Exchange:
|
||||
options: dict = {
|
||||
"apiKey": self.api_key,
|
||||
"secret": self.api_secret,
|
||||
"enableRateLimit": True,
|
||||
}
|
||||
return ccxt.bingx(options)
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ccxt
|
||||
|
||||
from app.exchange.base import AbstractExchange
|
||||
|
||||
|
||||
class BybitAdapter(AbstractExchange):
|
||||
"""Exchange adapter for Bybit."""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "bybit"
|
||||
|
||||
def get_base_url(self) -> str:
|
||||
return "https://api.bybit.com"
|
||||
|
||||
def get_ws_url(self) -> str:
|
||||
return "wss://stream.bybit.com/v5/public/spot"
|
||||
|
||||
def _init_ccxt(self) -> ccxt.Exchange:
|
||||
options: dict = {
|
||||
"apiKey": self.api_key,
|
||||
"secret": self.api_secret,
|
||||
"enableRateLimit": True,
|
||||
}
|
||||
if self.testnet:
|
||||
options["urls"] = {
|
||||
"api": {
|
||||
"public": "https://api-testnet.bybit.com",
|
||||
"private": "https://api-testnet.bybit.com",
|
||||
}
|
||||
}
|
||||
return ccxt.bybit(options)
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from app.exchange.base import AbstractExchange
|
||||
from app.exchange.binance import BinanceAdapter
|
||||
from app.exchange.bingx import BingXAdapter
|
||||
from app.exchange.bybit import BybitAdapter
|
||||
from app.exchange.gate import GateAdapter
|
||||
from app.exchange.mexc import MEXCAdapter
|
||||
|
||||
|
||||
class ExchangeFactory:
|
||||
"""Factory for creating exchange adapter instances.
|
||||
|
||||
Implements the Singleton pattern via a global instance.
|
||||
Caches adapter instances (TTL 5 min) to avoid repeated CCXT init (~20s).
|
||||
"""
|
||||
|
||||
_adapters: dict[str, type[AbstractExchange]] = {
|
||||
"binance": BinanceAdapter,
|
||||
"bingx": BingXAdapter,
|
||||
"bybit": BybitAdapter,
|
||||
"gate": GateAdapter,
|
||||
"mexc": MEXCAdapter,
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._cache: dict[str, tuple[AbstractExchange, float]] = {}
|
||||
self._cache_ttl: float = 300.0 # 5 minutes
|
||||
|
||||
def register(self, name: str, adapter_cls: type[AbstractExchange]) -> None:
|
||||
"""Register a new exchange adapter class."""
|
||||
self._adapters[name] = adapter_cls
|
||||
|
||||
def _cache_key(self, name: str, api_key: str) -> str:
|
||||
"""Generate a cache key from exchange name + API key."""
|
||||
return f"{name}:{hashlib.sha256(api_key.encode()).hexdigest()[:16]}"
|
||||
|
||||
def create(
|
||||
self,
|
||||
name: str,
|
||||
api_key: str = "",
|
||||
api_secret: str = "",
|
||||
testnet: bool = False,
|
||||
) -> AbstractExchange:
|
||||
"""Create (or retrieve cached) an exchange adapter instance by name.
|
||||
|
||||
Raises ValueError if the exchange is not registered.
|
||||
"""
|
||||
adapter_cls = self._adapters.get(name)
|
||||
if adapter_cls is None:
|
||||
raise ValueError(
|
||||
f"Unknown exchange: {name!r}. "
|
||||
f"Available exchanges: {', '.join(sorted(self._adapters))}"
|
||||
)
|
||||
|
||||
# Return cached adapter if still fresh
|
||||
if api_key:
|
||||
key = self._cache_key(name, api_key)
|
||||
cached = self._cache.get(key)
|
||||
if cached is not None:
|
||||
adapter, created_at = cached
|
||||
if time.monotonic() - created_at < self._cache_ttl:
|
||||
return adapter
|
||||
# Expired — remove from cache
|
||||
del self._cache[key]
|
||||
|
||||
adapter = adapter_cls(api_key=api_key, api_secret=api_secret, testnet=testnet)
|
||||
|
||||
# Warm up the CCXT client in the background — first balance call will trigger it
|
||||
# but subsequent calls within TTL reuse the same instance
|
||||
if api_key:
|
||||
key = self._cache_key(name, api_key)
|
||||
self._cache[key] = (adapter, time.monotonic())
|
||||
|
||||
return adapter
|
||||
|
||||
def get_available_exchanges(self) -> list[str]:
|
||||
"""Return a list of registered exchange names."""
|
||||
return list(self._adapters.keys())
|
||||
|
||||
|
||||
# Global singleton factory instance
|
||||
factory: ExchangeFactory = ExchangeFactory()
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ccxt
|
||||
|
||||
from app.exchange.base import AbstractExchange
|
||||
|
||||
|
||||
class GateAdapter(AbstractExchange):
|
||||
"""Exchange adapter for Gate.io."""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "gate"
|
||||
|
||||
def get_base_url(self) -> str:
|
||||
return "https://api.gateio.ws"
|
||||
|
||||
def get_ws_url(self) -> str:
|
||||
return "wss://api.gateio.ws/ws/v4/"
|
||||
|
||||
def _init_ccxt(self) -> ccxt.Exchange:
|
||||
options: dict = {
|
||||
"apiKey": self.api_key,
|
||||
"secret": self.api_secret,
|
||||
"enableRateLimit": True,
|
||||
}
|
||||
return ccxt.gate(options)
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ccxt
|
||||
|
||||
from app.exchange.base import AbstractExchange
|
||||
|
||||
|
||||
class MEXCAdapter(AbstractExchange):
|
||||
"""Exchange adapter for MEXC."""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "mexc"
|
||||
|
||||
def get_base_url(self) -> str:
|
||||
return "https://api.mexc.com"
|
||||
|
||||
def get_ws_url(self) -> str:
|
||||
return "wss://wbs.mexc.com/ws"
|
||||
|
||||
def _init_ccxt(self) -> ccxt.Exchange:
|
||||
options: dict = {
|
||||
"apiKey": self.api_key,
|
||||
"secret": self.api_secret,
|
||||
"enableRateLimit": True,
|
||||
}
|
||||
if self.testnet:
|
||||
options["urls"] = {
|
||||
"api": {
|
||||
"public": "https://testnet-api.mexc.com",
|
||||
"private": "https://testnet-api.mexc.com",
|
||||
}
|
||||
}
|
||||
return ccxt.mexc(options)
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
# Per-exchange rate limits (requests per second for standard API)
|
||||
BINANCE_RATE_LIMIT: int = 10
|
||||
BYBIT_RATE_LIMIT: int = 10
|
||||
MEXC_RATE_LIMIT: int = 20
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Token bucket rate limiter for exchange API requests."""
|
||||
|
||||
def __init__(self, tokens_per_second: float, max_tokens: Optional[int] = None, name: str = "") -> None:
|
||||
self.tokens_per_second = tokens_per_second
|
||||
self.max_tokens = max_tokens if max_tokens is not None else int(tokens_per_second)
|
||||
self.name = name
|
||||
self._tokens: float = float(self.max_tokens)
|
||||
self._last_refill: float = time.monotonic()
|
||||
self._lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self) -> None:
|
||||
"""Wait for a token to be available, blocking until one is."""
|
||||
while True:
|
||||
async with self._lock:
|
||||
self._refill()
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return
|
||||
# How long until we have at least 1 token?
|
||||
wait_time = (1.0 - self._tokens) / self.tokens_per_second
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
def _refill(self) -> None:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_refill
|
||||
self._tokens = min(float(self.max_tokens), self._tokens + elapsed * self.tokens_per_second)
|
||||
self._last_refill = now
|
||||
|
||||
async def __aenter__(self) -> "RateLimiter":
|
||||
await self.acquire()
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: Optional[type[BaseException]],
|
||||
exc_val: Optional[BaseException],
|
||||
exc_tb: Optional[object],
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# Global rate limiter registry: singleton mapping exchange_name -> RateLimiter instance
|
||||
_global_limiters: dict[str, RateLimiter] = {}
|
||||
|
||||
|
||||
def GlobalRateLimiter(exchange_name: str) -> RateLimiter:
|
||||
"""Get or create the singleton RateLimiter for an exchange."""
|
||||
if exchange_name not in _global_limiters:
|
||||
limit_map = {
|
||||
"binance": BINANCE_RATE_LIMIT,
|
||||
"bybit": BYBIT_RATE_LIMIT,
|
||||
"mexc": MEXC_RATE_LIMIT,
|
||||
}
|
||||
tokens_per_second = limit_map.get(exchange_name, 10)
|
||||
_global_limiters[exchange_name] = RateLimiter(
|
||||
tokens_per_second=tokens_per_second,
|
||||
name=exchange_name,
|
||||
)
|
||||
return _global_limiters[exchange_name]
|
||||
Executable
+146
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CandleData(BaseModel):
|
||||
symbol: str
|
||||
exchange: str
|
||||
timeframe: str
|
||||
timestamp: datetime
|
||||
open: Decimal
|
||||
high: Decimal
|
||||
low: Decimal
|
||||
close: Decimal
|
||||
volume: Decimal
|
||||
|
||||
|
||||
class SymbolInfo(BaseModel):
|
||||
symbol: str
|
||||
base: str
|
||||
quote: str
|
||||
exchange: str
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class TickerData(BaseModel):
|
||||
symbol: str
|
||||
exchange: str
|
||||
bid: Decimal
|
||||
ask: Decimal
|
||||
last: Decimal
|
||||
volume_24h: Decimal
|
||||
change_24h: Optional[Decimal] = None
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
class CandleValidationError(ValueError):
|
||||
"""Raised when OHLC data is inconsistent."""
|
||||
|
||||
def __init__(self, message: str, *, open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> None:
|
||||
self.open = open
|
||||
self.high = high
|
||||
self.low = low
|
||||
self.close = close
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class OrderRequest(BaseModel):
|
||||
"""Parameters for placing an order on an exchange."""
|
||||
symbol: str
|
||||
side: str # "buy" or "sell"
|
||||
order_type: str = "market" # "market" or "limit"
|
||||
amount: Decimal # base currency amount (e.g. BTC amount)
|
||||
price: Optional[Decimal] = None # required for limit orders
|
||||
reduce_only: bool = False
|
||||
position_side: Optional[str] = None # "long" or "short" (for futures)
|
||||
|
||||
|
||||
class OrderData(BaseModel):
|
||||
"""Response from placing an order."""
|
||||
exchange: str
|
||||
symbol: str
|
||||
order_id: str
|
||||
client_order_id: Optional[str] = None
|
||||
side: str
|
||||
order_type: str
|
||||
amount: Decimal
|
||||
filled: Decimal
|
||||
price: Optional[Decimal] = None
|
||||
average: Optional[Decimal] = None
|
||||
status: str # "open", "closed", "canceled", "rejected"
|
||||
timestamp: datetime
|
||||
raw: Optional[dict] = None
|
||||
|
||||
|
||||
class BalanceData(BaseModel):
|
||||
"""Account balance for a single asset."""
|
||||
asset: str
|
||||
free: Decimal
|
||||
used: Decimal
|
||||
total: Decimal
|
||||
|
||||
|
||||
class BalanceResponse(BaseModel):
|
||||
"""Full account balance snapshot."""
|
||||
exchange: str
|
||||
balances: list[BalanceData]
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
class OpenOrderData(BaseModel):
|
||||
"""An open order from the exchange."""
|
||||
order_id: str
|
||||
symbol: str
|
||||
side: str # "buy" or "sell"
|
||||
order_type: str # "market", "limit", etc.
|
||||
amount: Decimal
|
||||
filled: Decimal
|
||||
price: Optional[Decimal] = None
|
||||
average: Optional[Decimal] = None
|
||||
status: str
|
||||
timestamp: Optional[datetime] = None
|
||||
|
||||
|
||||
class PositionData(BaseModel):
|
||||
"""A futures/derivatives position."""
|
||||
symbol: str
|
||||
side: str # "long" or "short"
|
||||
contracts: Decimal
|
||||
entry_price: Optional[Decimal] = None
|
||||
mark_price: Optional[Decimal] = None
|
||||
unrealized_pnl: Optional[Decimal] = None
|
||||
leverage: Optional[Decimal] = None
|
||||
liquidation_price: Optional[Decimal] = None
|
||||
percentage: Optional[Decimal] = None
|
||||
|
||||
|
||||
class TradeData(BaseModel):
|
||||
"""A filled trade from the exchange."""
|
||||
trade_id: str
|
||||
symbol: str
|
||||
side: str # "buy" or "sell"
|
||||
amount: Decimal
|
||||
price: Decimal
|
||||
cost: Optional[Decimal] = None
|
||||
fee: Optional[Decimal] = None
|
||||
fee_currency: Optional[str] = None
|
||||
timestamp: Optional[datetime] = None
|
||||
|
||||
|
||||
class CredentialSummaryResponse(BaseModel):
|
||||
"""Aggregated summary for one credential."""
|
||||
exchange_name: str
|
||||
usdt_balance: Decimal
|
||||
usdt_estimate: str # "≈ $1,234.56" for non-USDT assets
|
||||
balance_count: int # number of tokens with balance > 0
|
||||
open_orders_count: int
|
||||
positions_count: int
|
||||
balances: list[BalanceData]
|
||||
open_orders: list[OpenOrderData]
|
||||
positions: list[PositionData]
|
||||
recent_trades: list[TradeData]
|
||||
Reference in New Issue
Block a user