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

0
execution/__init__.py Normal file
View File

View 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
View 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
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)

View 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
)