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