237 lines
8.0 KiB
Python
237 lines
8.0 KiB
Python
|
|
"""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
|
||
|
|
)
|