deploy: 2026-03-20 07:49
This commit is contained in:
0
execution/__init__.py
Normal file
0
execution/__init__.py
Normal file
172
execution/exchange_client.py
Normal file
172
execution/exchange_client.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""CCXT-based exchange client wrapper for FUTURES trading.
|
||||
|
||||
Provides a unified async interface to any CCXT-supported exchange
|
||||
with automatic sandbox/testnet support. Configured for futures
|
||||
(perpetual swaps) with leverage and margin mode management.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import ccxt.pro as ccxtpro
|
||||
import pandas as pd
|
||||
from loguru import logger
|
||||
|
||||
from config import settings
|
||||
|
||||
|
||||
class ExchangeClient:
|
||||
"""Async exchange client wrapping ccxt.pro for live and sandbox trading."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
exchange_id: str | None = None,
|
||||
api_key: str | None = None,
|
||||
api_secret: str | None = None,
|
||||
sandbox: bool | None = None,
|
||||
):
|
||||
self._exchange_id = exchange_id or settings.EXCHANGE_ID
|
||||
self._api_key = api_key or settings.API_KEY
|
||||
self._api_secret = api_secret or settings.API_SECRET
|
||||
self._sandbox = sandbox if sandbox is not None else settings.SANDBOX_MODE
|
||||
self._exchange: Optional[ccxtpro.Exchange] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Initialise and authenticate the exchange connection."""
|
||||
exchange_class = getattr(ccxtpro, self._exchange_id, None)
|
||||
if exchange_class is None:
|
||||
raise ValueError(f"Exchange '{self._exchange_id}' is not supported by ccxt.pro")
|
||||
|
||||
self._exchange = exchange_class(
|
||||
{
|
||||
"apiKey": self._api_key,
|
||||
"secret": self._api_secret,
|
||||
"enableRateLimit": True,
|
||||
"options": {"defaultType": "future"},
|
||||
}
|
||||
)
|
||||
|
||||
if self._sandbox:
|
||||
self._exchange.set_sandbox_mode(True)
|
||||
logger.info("Exchange connected in SANDBOX mode ({})", self._exchange_id)
|
||||
else:
|
||||
logger.info("Exchange connected in LIVE mode ({})", self._exchange_id)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Gracefully close the exchange connection."""
|
||||
if self._exchange:
|
||||
await self._exchange.close()
|
||||
logger.info("Exchange disconnected")
|
||||
|
||||
async def is_connected(self) -> bool:
|
||||
"""Return True if the exchange instance is initialised."""
|
||||
return self._exchange is not None
|
||||
|
||||
@property
|
||||
def exchange(self) -> ccxtpro.Exchange:
|
||||
if self._exchange is None:
|
||||
raise RuntimeError("Exchange not connected. Call connect() first.")
|
||||
return self._exchange
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Market data
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def watch_ohlcv(self, symbol: str, timeframe: str = "1m") -> List:
|
||||
"""Watch real-time OHLCV candles via WebSocket."""
|
||||
return await self.exchange.watch_ohlcv(symbol, timeframe)
|
||||
|
||||
async def fetch_ohlcv(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str = "1h",
|
||||
since: int | None = None,
|
||||
limit: int = 500,
|
||||
) -> pd.DataFrame:
|
||||
"""Fetch historical OHLCV data and return as a 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)
|
||||
return df
|
||||
|
||||
async def fetch_balance(self) -> Dict[str, Any]:
|
||||
"""Fetch account balance."""
|
||||
return await self.exchange.fetch_balance()
|
||||
|
||||
async def fetch_ticker(self, symbol: str) -> Dict[str, Any]:
|
||||
"""Fetch current ticker information."""
|
||||
return await self.exchange.fetch_ticker(symbol)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Order management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def create_limit_buy(self, symbol: str, amount: float, price: float) -> Dict:
|
||||
"""Place a limit buy order."""
|
||||
logger.info("LIMIT BUY {} {} @ {}", symbol, amount, price)
|
||||
return await self.exchange.create_limit_buy_order(symbol, amount, price)
|
||||
|
||||
async def create_limit_sell(self, symbol: str, amount: float, price: float) -> Dict:
|
||||
"""Place a limit sell order."""
|
||||
logger.info("LIMIT SELL {} {} @ {}", symbol, amount, price)
|
||||
return await self.exchange.create_limit_sell_order(symbol, amount, price)
|
||||
|
||||
async def create_market_buy(self, symbol: str, amount: float) -> Dict:
|
||||
"""Place a market buy order."""
|
||||
logger.info("MARKET BUY {} {}", symbol, amount)
|
||||
return await self.exchange.create_market_buy_order(symbol, amount)
|
||||
|
||||
async def create_market_sell(self, symbol: str, amount: float) -> Dict:
|
||||
"""Place a market sell order."""
|
||||
logger.info("MARKET SELL {} {}", symbol, amount)
|
||||
return await self.exchange.create_market_sell_order(symbol, amount)
|
||||
|
||||
async def create_stop_loss(
|
||||
self, symbol: str, side: str, amount: float, stop_price: float
|
||||
) -> Dict:
|
||||
"""Place a stop-loss order (stop-market)."""
|
||||
logger.info("STOP {} {} {} trigger={}", side, symbol, amount, stop_price)
|
||||
params = {"stopPrice": stop_price}
|
||||
return await self.exchange.create_order(
|
||||
symbol, "stop", side, amount, stop_price, params
|
||||
)
|
||||
|
||||
async def cancel_order(self, order_id: str, symbol: str) -> Dict:
|
||||
"""Cancel an existing order."""
|
||||
logger.info("CANCEL order {} on {}", order_id, symbol)
|
||||
return await self.exchange.cancel_order(order_id, symbol)
|
||||
|
||||
async def fetch_order(self, order_id: str, symbol: str) -> Dict:
|
||||
"""Fetch a single order by id."""
|
||||
return await self.exchange.fetch_order(order_id, symbol)
|
||||
|
||||
async def fetch_open_orders(self, symbol: str | None = None) -> List[Dict]:
|
||||
"""Fetch all open orders, optionally filtered by symbol."""
|
||||
return await self.exchange.fetch_open_orders(symbol)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Futures-specific
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def set_leverage(self, symbol: str, leverage: int) -> None:
|
||||
"""Set leverage for a futures symbol."""
|
||||
try:
|
||||
await self.exchange.set_leverage(leverage, symbol)
|
||||
logger.info("Set leverage {} = {}x", symbol, leverage)
|
||||
except Exception as e:
|
||||
logger.warning("set_leverage failed for {} (may already be set): {}", symbol, e)
|
||||
|
||||
async def set_margin_mode(self, symbol: str, mode: str = "isolated") -> None:
|
||||
"""Set margin mode (isolated/cross) for a futures symbol."""
|
||||
try:
|
||||
await self.exchange.set_margin_mode(mode, symbol)
|
||||
logger.info("Set margin mode {} = {}", symbol, mode)
|
||||
except Exception as e:
|
||||
logger.warning("set_margin_mode failed for {} (may already be set): {}", symbol, e)
|
||||
234
execution/order_manager.py
Normal file
234
execution/order_manager.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""Order execution and management.
|
||||
|
||||
Handles the lifecycle of exchange orders: creation, monitoring,
|
||||
cancellation, and retry logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from execution.exchange_client import ExchangeClient
|
||||
from risk.risk_manager import RiskManager
|
||||
from strategy.signal_generator import TradeSignal
|
||||
from indicators.multi_timeframe import TradeDirection
|
||||
|
||||
|
||||
class OrderStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
FILLED = "FILLED"
|
||||
PARTIALLY_FILLED = "PARTIALLY_FILLED"
|
||||
CANCELLED = "CANCELLED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Order:
|
||||
"""Represents an exchange order."""
|
||||
|
||||
id: str
|
||||
symbol: str
|
||||
side: str # "buy" or "sell"
|
||||
order_type: str # "market", "limit", "stop"
|
||||
amount: float
|
||||
price: Optional[float] = None
|
||||
stop_price: Optional[float] = None
|
||||
status: OrderStatus = OrderStatus.PENDING
|
||||
exchange_order_id: Optional[str] = None
|
||||
filled_price: Optional[float] = None
|
||||
filled_amount: float = 0.0
|
||||
fee: float = 0.0
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
updated_at: Optional[datetime] = None
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class OrderManager:
|
||||
"""Manage order execution against the exchange.
|
||||
|
||||
Flow for signal execution:
|
||||
1. Get RiskManager approval
|
||||
2. Calculate position size
|
||||
3. Place entry order (limit first, fallback to market)
|
||||
4. Place SL/TP orders
|
||||
5. Record results
|
||||
"""
|
||||
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 1.0 # seconds
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
exchange_client: ExchangeClient,
|
||||
risk_manager: RiskManager,
|
||||
):
|
||||
self.client = exchange_client
|
||||
self.risk = risk_manager
|
||||
self._orders: Dict[str, Order] = {}
|
||||
|
||||
async def execute_signal(self, signal: TradeSignal, balance: float) -> Optional[Order]:
|
||||
"""Execute a trade signal end-to-end.
|
||||
|
||||
Returns the filled entry Order, or None if rejected/failed.
|
||||
"""
|
||||
# 1. Risk approval
|
||||
approval = self.risk.approve_trade(
|
||||
entry_price=signal.entry_price,
|
||||
stop_loss=signal.stop_loss,
|
||||
balance=balance,
|
||||
)
|
||||
|
||||
if not approval.approved:
|
||||
logger.warning(
|
||||
"Trade REJECTED for {}: {}", signal.symbol, approval.reason
|
||||
)
|
||||
return None
|
||||
|
||||
size = approval.position_size
|
||||
side = "buy" if signal.direction == TradeDirection.LONG else "sell"
|
||||
|
||||
# 2. Place entry order (try limit, fallback to market)
|
||||
entry_order = await self._place_with_retry(
|
||||
signal.symbol, side, "limit", size, signal.entry_price
|
||||
)
|
||||
if entry_order is None or entry_order.status == OrderStatus.FAILED:
|
||||
logger.warning("Limit order failed, trying market order")
|
||||
entry_order = await self._place_with_retry(
|
||||
signal.symbol, side, "market", size
|
||||
)
|
||||
|
||||
if entry_order is None or entry_order.status == OrderStatus.FAILED:
|
||||
logger.error("Failed to place entry order for {}", signal.symbol)
|
||||
return None
|
||||
|
||||
# 3. Place SL order
|
||||
sl_side = "sell" if side == "buy" else "buy"
|
||||
await self._place_stop_loss(
|
||||
signal.symbol, sl_side, size, signal.stop_loss
|
||||
)
|
||||
|
||||
self.risk.on_position_opened()
|
||||
logger.info(
|
||||
"Order executed: {} {} {} @ {}",
|
||||
side.upper(), signal.symbol, size, entry_order.filled_price or signal.entry_price,
|
||||
)
|
||||
return entry_order
|
||||
|
||||
async def create_order(
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
price: float | None = None,
|
||||
) -> Order:
|
||||
"""Create a single order on the exchange."""
|
||||
order_id = str(uuid.uuid4())[:8]
|
||||
order = Order(
|
||||
id=order_id,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
amount=amount,
|
||||
price=price,
|
||||
)
|
||||
|
||||
try:
|
||||
if order_type == "market":
|
||||
if side == "buy":
|
||||
raw = await self.client.create_market_buy(symbol, amount)
|
||||
else:
|
||||
raw = await self.client.create_market_sell(symbol, amount)
|
||||
elif order_type == "limit" and price is not None:
|
||||
if side == "buy":
|
||||
raw = await self.client.create_limit_buy(symbol, amount, price)
|
||||
else:
|
||||
raw = await self.client.create_limit_sell(symbol, amount, price)
|
||||
else:
|
||||
raise ValueError(f"Unsupported order type: {order_type}")
|
||||
|
||||
order.exchange_order_id = raw.get("id")
|
||||
order.status = OrderStatus.FILLED if raw.get("status") == "closed" else OrderStatus.PENDING
|
||||
order.filled_price = raw.get("average") or raw.get("price")
|
||||
order.filled_amount = raw.get("filled", 0.0)
|
||||
order.fee = raw.get("fee", {}).get("cost", 0.0)
|
||||
order.raw = raw
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Order failed: {} {} {} -- {}", side, symbol, amount, e)
|
||||
order.status = OrderStatus.FAILED
|
||||
|
||||
order.updated_at = datetime.utcnow()
|
||||
self._orders[order.id] = order
|
||||
return order
|
||||
|
||||
async def cancel_order(self, order_id: str, symbol: str) -> bool:
|
||||
"""Cancel an order on the exchange."""
|
||||
try:
|
||||
order = self._orders.get(order_id)
|
||||
exchange_id = order.exchange_order_id if order else order_id
|
||||
await self.client.cancel_order(exchange_id, symbol)
|
||||
if order:
|
||||
order.status = OrderStatus.CANCELLED
|
||||
order.updated_at = datetime.utcnow()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Cancel failed for {}: {}", order_id, e)
|
||||
return False
|
||||
|
||||
async def _place_with_retry(
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
price: float | None = None,
|
||||
) -> Optional[Order]:
|
||||
"""Place an order with automatic retry on failure."""
|
||||
for attempt in range(1, self.MAX_RETRIES + 1):
|
||||
order = await self.create_order(symbol, side, order_type, amount, price)
|
||||
if order.status != OrderStatus.FAILED:
|
||||
return order
|
||||
logger.warning(
|
||||
"Retry {}/{} for {} {} {}", attempt, self.MAX_RETRIES, side, symbol, order_type
|
||||
)
|
||||
await asyncio.sleep(self.RETRY_DELAY * attempt)
|
||||
return None
|
||||
|
||||
async def _place_stop_loss(
|
||||
self, symbol: str, side: str, amount: float, stop_price: float
|
||||
) -> Optional[Order]:
|
||||
"""Place a stop-loss order."""
|
||||
order_id = str(uuid.uuid4())[:8]
|
||||
order = Order(
|
||||
id=order_id,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
order_type="stop",
|
||||
amount=amount,
|
||||
stop_price=stop_price,
|
||||
)
|
||||
try:
|
||||
raw = await self.client.create_stop_loss(symbol, side, amount, stop_price)
|
||||
order.exchange_order_id = raw.get("id")
|
||||
order.status = OrderStatus.PENDING
|
||||
order.raw = raw
|
||||
except Exception as e:
|
||||
logger.error("Stop-loss order failed: {}", e)
|
||||
order.status = OrderStatus.FAILED
|
||||
|
||||
self._orders[order.id] = order
|
||||
return order
|
||||
|
||||
def get_order(self, order_id: str) -> Optional[Order]:
|
||||
return self._orders.get(order_id)
|
||||
|
||||
def get_open_orders(self) -> List[Order]:
|
||||
return [o for o in self._orders.values() if o.status == OrderStatus.PENDING]
|
||||
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)
|
||||
236
execution/position_manager.py
Normal file
236
execution/position_manager.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Position lifecycle management.
|
||||
|
||||
Tracks open positions, evaluates exit conditions on each tick,
|
||||
and coordinates closing via the OrderManager.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from execution.order_manager import Order, OrderManager
|
||||
from indicators.ict_engine import ICTEngine, ICTSignals
|
||||
from indicators.multi_timeframe import TradeDirection
|
||||
from risk.risk_manager import RiskManager
|
||||
from strategy.exit_rules import ExitRules, ExitReason
|
||||
from strategy.signal_generator import TradeSignal
|
||||
|
||||
|
||||
class PositionStatus(str, Enum):
|
||||
OPEN = "OPEN"
|
||||
CLOSED = "CLOSED"
|
||||
LIQUIDATED = "LIQUIDATED"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Position:
|
||||
"""Represents an open or closed trading position."""
|
||||
|
||||
id: str
|
||||
symbol: str
|
||||
direction: TradeDirection
|
||||
entry_price: float
|
||||
current_price: float
|
||||
amount: float
|
||||
stop_loss: float
|
||||
take_profit: float
|
||||
trailing_stop: Optional[float] = None
|
||||
unrealized_pnl: float = 0.0
|
||||
realized_pnl: float = 0.0
|
||||
status: PositionStatus = PositionStatus.OPEN
|
||||
opened_at: datetime = field(default_factory=datetime.utcnow)
|
||||
closed_at: Optional[datetime] = None
|
||||
close_reason: Optional[str] = None
|
||||
confluence_score: int = 0
|
||||
entry_reasons: List[str] = field(default_factory=list)
|
||||
candles_since_entry: int = 0
|
||||
entry_order_id: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self.status == PositionStatus.OPEN
|
||||
|
||||
def update_price(self, price: float) -> None:
|
||||
"""Update current price and recalculate unrealised PnL."""
|
||||
self.current_price = price
|
||||
if self.direction == TradeDirection.LONG:
|
||||
self.unrealized_pnl = (price - self.entry_price) * self.amount
|
||||
else:
|
||||
self.unrealized_pnl = (self.entry_price - price) * self.amount
|
||||
|
||||
|
||||
class PositionManager:
|
||||
"""Manage the lifecycle of all open positions.
|
||||
|
||||
Responsibilities:
|
||||
- Open positions from filled orders
|
||||
- Evaluate exit conditions each tick
|
||||
- Close positions and record results
|
||||
- Track trailing stops
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
order_manager: OrderManager,
|
||||
risk_manager: RiskManager,
|
||||
exit_rules: ExitRules | None = None,
|
||||
):
|
||||
self.orders = order_manager
|
||||
self.risk = risk_manager
|
||||
self.exit_rules = exit_rules or ExitRules()
|
||||
self._positions: Dict[str, Position] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Open
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def open_position(self, signal: TradeSignal, order: Order) -> Position:
|
||||
"""Create a new Position from a filled entry order."""
|
||||
pos_id = str(uuid.uuid4())[:8]
|
||||
position = Position(
|
||||
id=pos_id,
|
||||
symbol=signal.symbol,
|
||||
direction=signal.direction,
|
||||
entry_price=order.filled_price or signal.entry_price,
|
||||
current_price=order.filled_price or signal.entry_price,
|
||||
amount=order.filled_amount or order.amount,
|
||||
stop_loss=signal.stop_loss,
|
||||
take_profit=signal.take_profit,
|
||||
confluence_score=signal.confidence,
|
||||
entry_reasons=signal.reasons,
|
||||
entry_order_id=order.id,
|
||||
)
|
||||
self._positions[pos_id] = position
|
||||
logger.info(
|
||||
"Position OPENED: {} {} {} @ {} (SL={}, TP={})",
|
||||
pos_id, signal.direction.value, signal.symbol,
|
||||
position.entry_price, position.stop_loss, position.take_profit,
|
||||
)
|
||||
return position
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Update / check exit
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def update_positions(
|
||||
self, symbol: str, current_price: float, signals: ICTSignals
|
||||
) -> List[Position]:
|
||||
"""Update all open positions for a symbol and close any that trigger exit.
|
||||
|
||||
Returns list of positions that were closed during this update.
|
||||
"""
|
||||
closed: List[Position] = []
|
||||
|
||||
for pos in self._get_open_for_symbol(symbol):
|
||||
pos.update_price(current_price)
|
||||
pos.candles_since_entry += 1
|
||||
|
||||
# Update trailing stop
|
||||
pos.trailing_stop = self.exit_rules.update_trailing_stop(
|
||||
pos.direction, pos.entry_price, current_price, pos.trailing_stop
|
||||
)
|
||||
|
||||
# Check exit conditions
|
||||
result = self.exit_rules.should_exit(
|
||||
direction=pos.direction,
|
||||
entry_price=pos.entry_price,
|
||||
stop_loss=pos.stop_loss,
|
||||
take_profit=pos.take_profit,
|
||||
current_price=current_price,
|
||||
signals=signals,
|
||||
opened_at=pos.opened_at,
|
||||
candles_since_entry=pos.candles_since_entry,
|
||||
trailing_stop=pos.trailing_stop,
|
||||
)
|
||||
|
||||
if result.should_exit:
|
||||
await self._close_position(pos, current_price, result.reason)
|
||||
closed.append(pos)
|
||||
|
||||
return closed
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Close
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _close_position(
|
||||
self, position: Position, price: float, reason: ExitReason | None
|
||||
) -> None:
|
||||
"""Close a position by placing a closing order."""
|
||||
close_side = "sell" if position.direction == TradeDirection.LONG else "buy"
|
||||
|
||||
try:
|
||||
await self.orders.create_order(
|
||||
position.symbol, close_side, "market", position.amount
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to close position {}: {}", position.id, e)
|
||||
|
||||
# Calculate realized PnL
|
||||
if position.direction == TradeDirection.LONG:
|
||||
position.realized_pnl = (price - position.entry_price) * position.amount
|
||||
else:
|
||||
position.realized_pnl = (position.entry_price - price) * position.amount
|
||||
|
||||
position.status = PositionStatus.CLOSED
|
||||
position.closed_at = datetime.utcnow()
|
||||
position.close_reason = reason.value if reason else "MANUAL"
|
||||
position.current_price = price
|
||||
position.unrealized_pnl = 0.0
|
||||
|
||||
# Update risk manager
|
||||
self.risk.update_daily_pnl(position.realized_pnl)
|
||||
self.risk.on_position_closed()
|
||||
|
||||
logger.info(
|
||||
"Position CLOSED: {} {} PnL={:.2f} reason={}",
|
||||
position.id, position.symbol, position.realized_pnl, position.close_reason,
|
||||
)
|
||||
|
||||
async def close_all(self, reason: str = "MANUAL") -> List[Position]:
|
||||
"""Emergency close all open positions."""
|
||||
closed: List[Position] = []
|
||||
for pos in list(self._positions.values()):
|
||||
if pos.is_open:
|
||||
await self._close_position(pos, pos.current_price, ExitReason.MANUAL)
|
||||
closed.append(pos)
|
||||
return closed
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_open_for_symbol(self, symbol: str) -> List[Position]:
|
||||
return [
|
||||
p for p in self._positions.values()
|
||||
if p.is_open and p.symbol == symbol
|
||||
]
|
||||
|
||||
def get_open_positions(self) -> List[Position]:
|
||||
return [p for p in self._positions.values() if p.is_open]
|
||||
|
||||
def get_all_positions(self) -> List[Position]:
|
||||
return list(self._positions.values())
|
||||
|
||||
def get_position(self, position_id: str) -> Optional[Position]:
|
||||
return self._positions.get(position_id)
|
||||
|
||||
@property
|
||||
def open_count(self) -> int:
|
||||
return len(self.get_open_positions())
|
||||
|
||||
def get_total_unrealized_pnl(self) -> float:
|
||||
return sum(p.unrealized_pnl for p in self.get_open_positions())
|
||||
|
||||
def get_total_realized_pnl(self) -> float:
|
||||
return sum(
|
||||
p.realized_pnl
|
||||
for p in self._positions.values()
|
||||
if p.status == PositionStatus.CLOSED
|
||||
)
|
||||
Reference in New Issue
Block a user