deploy: 2026-03-20 07:49
This commit is contained in:
331
execution/paper_exchange.py
Normal file
331
execution/paper_exchange.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user