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

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
)