deploy: 2026-03-20 07:49

This commit is contained in:
ufo6849
2026-03-20 07:49:42 +09:00
commit d14a8bab04
73 changed files with 76534 additions and 0 deletions

331
execution/paper_exchange.py Normal file
View 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)