"""Paper trading exchange client for FUTURES. Uses public CCXT REST API for real market data but simulates all order execution locally. No API keys required. Supports both LONG and SHORT with leverage and margin tracking. """ from __future__ import annotations import asyncio import uuid from datetime import datetime from typing import Any, Dict, List, Optional import ccxt.async_support as ccxt_async import pandas as pd from loguru import logger from config import settings class PaperExchangeClient: """Simulated futures exchange client for paper trading. - Market data: fetched from real exchange (public endpoints, no auth) - Orders: executed locally at current market price with simulated fills - Balance: tracked as margin account with leverage support - Supports LONG and SHORT positions natively """ def __init__(self, initial_balance: float = 1000.0): self._exchange: Optional[ccxt_async.Exchange] = None self._balance: Dict[str, float] = { "USDT": initial_balance, } self._initial_balance = initial_balance # Futures: track margin per position, not asset amounts self._futures_positions: Dict[str, Dict] = {} # symbol -> {side, amount, entry_price, margin, leverage} self._orders: List[Dict] = [] self._last_prices: Dict[str, float] = {} async def connect(self) -> None: """Connect to exchange using public API only (no auth needed).""" exchange_id = settings.EXCHANGE_ID exchange_class = getattr(ccxt_async, exchange_id, None) if exchange_class is None: raise ValueError(f"Exchange '{exchange_id}' not supported") self._exchange = exchange_class({ "enableRateLimit": True, "options": {"defaultType": "future"}, }) logger.info("Paper FUTURES exchange connected ({}), balance: ${:.2f}", exchange_id, self._balance["USDT"]) async def disconnect(self) -> None: if self._exchange: await self._exchange.close() logger.info("Paper exchange disconnected") async def is_connected(self) -> bool: return self._exchange is not None @property def exchange(self): if self._exchange is None: raise RuntimeError("Exchange not connected") return self._exchange # ------------------------------------------------------------------ # Market data (real public API) # ------------------------------------------------------------------ async def fetch_ohlcv( self, symbol: str, timeframe: str = "1h", since: int | None = None, limit: int = 500, ) -> pd.DataFrame: data = await self.exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=limit) df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"]) df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") df.set_index("timestamp", inplace=True) if not df.empty: self._last_prices[symbol] = float(df["close"].iloc[-1]) # Check stop losses on price update self._check_stop_losses(symbol, float(df["close"].iloc[-1])) return df async def fetch_balance(self) -> Dict[str, Any]: """Return futures account balance including unrealized PnL.""" total_value = self._balance.get("USDT", 0) total_unrealized = 0.0 for symbol, pos in self._futures_positions.items(): price = self._last_prices.get(symbol, pos["entry_price"]) unrealized = self._calc_unrealized_pnl(pos, price) total_unrealized += unrealized return { "free": {"USDT": self._balance.get("USDT", 0)}, "used": {"USDT": sum(p["margin"] for p in self._futures_positions.values())}, "total": {"USDT": total_value + total_unrealized}, } async def fetch_ticker(self, symbol: str) -> Dict[str, Any]: ticker = await self.exchange.fetch_ticker(symbol) self._last_prices[symbol] = ticker.get("last", 0) return ticker async def watch_ohlcv(self, symbol: str, timeframe: str = "1m") -> List: """Simulate watch by polling fetch_ohlcv.""" df = await self.fetch_ohlcv(symbol, timeframe, limit=5) if df.empty: return [] rows = [] for ts, row in df.iterrows(): rows.append([ int(ts.timestamp() * 1000), row["open"], row["high"], row["low"], row["close"], row["volume"] ]) return rows # ------------------------------------------------------------------ # Futures position helpers # ------------------------------------------------------------------ def _calc_unrealized_pnl(self, pos: Dict, current_price: float) -> float: """Calculate unrealized PnL for a futures position.""" if pos["side"] == "long": return (current_price - pos["entry_price"]) * pos["amount"] else: # short return (pos["entry_price"] - current_price) * pos["amount"] # ------------------------------------------------------------------ # Paper order execution (Futures style) # ------------------------------------------------------------------ def _get_price(self, symbol: str) -> float: return self._last_prices.get(symbol, 0) def _simulate_fill(self, symbol: str, side: str, amount: float, price: float | None) -> Dict: """Simulate a futures order fill. For futures: - 'buy' opens LONG or closes SHORT - 'sell' opens SHORT or closes LONG """ fill_price = price or self._get_price(symbol) if fill_price <= 0: raise ValueError(f"No price available for {symbol}") leverage = settings.DEFAULT_LEVERAGE notional = amount * fill_price margin_required = notional / leverage fee_rate = 0.0004 # 0.04% taker fee (Binance Futures) fee = notional * fee_rate existing = self._futures_positions.get(symbol) if existing: # Closing existing position if (existing["side"] == "long" and side == "sell") or \ (existing["side"] == "short" and side == "buy"): # Calculate realized PnL pnl = self._calc_unrealized_pnl(existing, fill_price) # Return margin + PnL - fee self._balance["USDT"] += existing["margin"] + pnl - fee del self._futures_positions[symbol] logger.info( "PAPER CLOSE %s %s %.6f @ $%,.2f | PnL=$%,.2f fee=$%.4f | balance=$%,.2f", existing["side"].upper(), symbol, amount, fill_price, pnl, fee, self._balance["USDT"] ) else: # Adding to same direction - not supported for simplicity raise ValueError(f"Cannot add to existing {existing['side']} position on {symbol}") else: # Opening new position if self._balance.get("USDT", 0) < margin_required + fee: raise ValueError( f"Insufficient margin: need {margin_required + fee:.2f}, " f"have {self._balance.get('USDT', 0):.2f}" ) self._balance["USDT"] -= (margin_required + fee) pos_side = "long" if side == "buy" else "short" self._futures_positions[symbol] = { "side": pos_side, "amount": amount, "entry_price": fill_price, "margin": margin_required, "leverage": leverage, "opened_at": datetime.utcnow().isoformat(), } logger.info( "PAPER OPEN %s %s %.6f @ $%,.2f | margin=$%,.2f (%.0fx) fee=$%.4f | balance=$%,.2f", pos_side.upper(), symbol, amount, fill_price, margin_required, leverage, fee, self._balance["USDT"] ) order = { "id": str(uuid.uuid4())[:8], "symbol": symbol, "side": side, "type": "market", "amount": amount, "price": fill_price, "average": fill_price, "filled": amount, "remaining": 0, "cost": notional, "fee": {"cost": fee, "currency": "USDT"}, "status": "closed", "timestamp": int(datetime.utcnow().timestamp() * 1000), "datetime": datetime.utcnow().isoformat(), } self._orders.append(order) return order async def create_limit_buy(self, symbol: str, amount: float, price: float) -> Dict: return self._simulate_fill(symbol, "buy", amount, price) async def create_limit_sell(self, symbol: str, amount: float, price: float) -> Dict: return self._simulate_fill(symbol, "sell", amount, price) async def create_market_buy(self, symbol: str, amount: float) -> Dict: return self._simulate_fill(symbol, "buy", amount, None) async def create_market_sell(self, symbol: str, amount: float) -> Dict: return self._simulate_fill(symbol, "sell", amount, None) async def create_stop_loss(self, symbol: str, side: str, amount: float, stop_price: float) -> Dict: """Register a stop-loss order that triggers on price.""" sl_id = str(uuid.uuid4())[:8] sl_order = { "id": sl_id, "symbol": symbol, "side": side, "type": "stop", "amount": amount, "stopPrice": stop_price, "status": "open", } self._orders.append(sl_order) logger.info("PAPER STOP %s %s %.6f trigger=$%,.2f", side, symbol, amount, stop_price) return sl_order def _check_stop_losses(self, symbol: str, current_price: float) -> None: """Check and trigger any stop-loss orders for this symbol.""" triggered = [] for order in self._orders: if (order.get("type") == "stop" and order.get("symbol") == symbol and order.get("status") == "open"): stop_price = order["stopPrice"] side = order["side"] # sell stop triggers when price <= stop (long SL) # buy stop triggers when price >= stop (short SL) if (side == "sell" and current_price <= stop_price) or \ (side == "buy" and current_price >= stop_price): triggered.append(order) for order in triggered: order["status"] = "triggered" try: self._simulate_fill(symbol, order["side"], order["amount"], current_price) logger.warning( "STOP-LOSS TRIGGERED: %s %s @ $%,.2f (stop=$%,.2f)", order["side"].upper(), symbol, current_price, order["stopPrice"] ) except Exception as e: logger.error("Stop-loss execution failed: %s", e) async def cancel_order(self, order_id: str, symbol: str) -> Dict: for order in self._orders: if order["id"] == order_id: order["status"] = "cancelled" logger.info("PAPER CANCEL order %s on %s", order_id, symbol) return {"id": order_id, "status": "cancelled"} async def fetch_order(self, order_id: str, symbol: str) -> Dict: for o in self._orders: if o["id"] == order_id: return o return {"id": order_id, "status": "unknown"} async def fetch_open_orders(self, symbol: str | None = None) -> List[Dict]: return [o for o in self._orders if o.get("status") == "open" and (symbol is None or o.get("symbol") == symbol)] # ------------------------------------------------------------------ # Portfolio info # ------------------------------------------------------------------ def get_portfolio_summary(self) -> Dict: total = self._balance.get("USDT", 0) positions = {} for sym, pos in self._futures_positions.items(): price = self._last_prices.get(sym, pos["entry_price"]) unrealized = self._calc_unrealized_pnl(pos, price) total += pos["margin"] + unrealized positions[sym] = { "side": pos["side"], "amount": pos["amount"], "entry_price": pos["entry_price"], "current_price": price, "margin": pos["margin"], "leverage": pos["leverage"], "unrealized_pnl": unrealized, } return { "initial_balance": self._initial_balance, "available_balance": self._balance.get("USDT", 0), "total_value": total, "pnl": total - self._initial_balance, "pnl_pct": (total - self._initial_balance) / self._initial_balance * 100, "positions": positions, "total_trades": len([o for o in self._orders if o.get("type") != "stop"]), } # ------------------------------------------------------------------ # Futures-specific: set leverage (for live compatibility) # ------------------------------------------------------------------ async def set_leverage(self, symbol: str, leverage: int) -> None: """Set leverage for a symbol (no-op in paper, used in live).""" logger.info("PAPER set leverage %s = %dx", symbol, leverage) async def set_margin_mode(self, symbol: str, mode: str = "isolated") -> None: """Set margin mode (no-op in paper, used in live).""" logger.info("PAPER set margin mode %s = %s", symbol, mode)