Initial commit: Trading Portal - FastAPI + React + PostgreSQL

This commit is contained in:
2026-07-03 13:08:09 +00:00
commit 34a1e91541
198 changed files with 35110 additions and 0 deletions
View File
+344
View File
@@ -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
+37
View File
@@ -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)
+26
View File
@@ -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)
+33
View File
@@ -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)
+87
View File
@@ -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()
+26
View File
@@ -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)
+33
View File
@@ -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)
+72
View File
@@ -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]
+146
View File
@@ -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]