332 lines
14 KiB
Python
332 lines
14 KiB
Python
"""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)
|