deploy: 2026-03-20 07:49
This commit is contained in:
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