"""Position Tracker — real-time position & PnL tracking. Monitors active positions, calculates unrealized PnL based on current Polymarket prices, and enforces exposure limits. """ from __future__ import annotations import time from typing import Optional import structlog from src.config import Config from src.data.models import ( Asset, Direction, OrderBookSnapshot, Position, Timeframe, Trade, TradeStatus, ) log = structlog.get_logger() class PositionTracker: """Tracks all open positions and computes real-time PnL. Positions are created from filled trades and closed when the underlying market resolves or the position is sold. """ def __init__(self, config: Config) -> None: self.config = config self._positions: dict[str, Position] = {} # keyed by token_id self._realized_pnl: float = 0.0 self._total_fees: float = 0.0 self._trade_count: int = 0 self._win_count: int = 0 # ------------------------------------------------------------------ # Position management # ------------------------------------------------------------------ def open_position(self, trade: Trade) -> Position: """Open or add to a position from a filled trade.""" token_id = trade.signal.token_id signal = trade.signal if token_id in self._positions: # Add to existing position pos = self._positions[token_id] total_cost = pos.avg_price * pos.size + trade.fill_price * trade.fill_size new_size = pos.size + trade.fill_size pos.avg_price = total_cost / new_size if new_size > 0 else 0 pos.size = new_size else: pos = Position( market_id=token_id, asset=signal.asset, timeframe=signal.timeframe, direction=signal.direction, token_id=token_id, size=trade.fill_size, avg_price=trade.fill_price, ) self._positions[token_id] = pos self._trade_count += 1 self._total_fees += trade.fee log.info( "position_opened", token_id=token_id[:16], asset=signal.asset.value, direction=signal.direction.value, size=pos.size, avg_price=round(pos.avg_price, 4), ) return pos def close_position(self, token_id: str, resolution_price: float) -> Optional[float]: """Close a position at resolution. Args: token_id: The token ID of the position. resolution_price: 1.0 if the outcome won, 0.0 if lost. Returns: Realized PnL for this position, or None if no position. """ pos = self._positions.pop(token_id, None) if pos is None: return None payout = resolution_price * pos.size cost = pos.avg_price * pos.size pnl = payout - cost self._realized_pnl += pnl if pnl > 0: self._win_count += 1 log.info( "position_closed", token_id=token_id[:16], asset=pos.asset.value, direction=pos.direction.value, size=pos.size, avg_price=round(pos.avg_price, 4), payout=round(payout, 2), pnl=round(pnl, 2), result="WIN" if pnl > 0 else "LOSS", ) return pnl # ------------------------------------------------------------------ # Mark-to-market # ------------------------------------------------------------------ def update_mark(self, token_id: str, current_price: float) -> None: """Update mark-to-market for a position.""" pos = self._positions.get(token_id) if pos is None: return pos.current_value = current_price * pos.size pos.unrealized_pnl = pos.current_value - (pos.avg_price * pos.size) def update_from_orderbooks(self, orderbooks: dict[str, OrderBookSnapshot]) -> None: """Update all positions from latest orderbook data.""" for token_id, pos in self._positions.items(): book = orderbooks.get(token_id) if book and book.best_bid is not None: self.update_mark(token_id, book.best_bid) # ------------------------------------------------------------------ # Queries # ------------------------------------------------------------------ def get_position(self, token_id: str) -> Optional[Position]: return self._positions.get(token_id) def get_all_positions(self) -> list[Position]: return list(self._positions.values()) def get_positions_by_asset(self, asset: str) -> list[Position]: a = Asset(asset) return [p for p in self._positions.values() if p.asset == a] @property def total_unrealized_pnl(self) -> float: return sum(p.unrealized_pnl for p in self._positions.values()) @property def total_realized_pnl(self) -> float: return self._realized_pnl @property def total_pnl(self) -> float: return self._realized_pnl + self.total_unrealized_pnl @property def total_fees(self) -> float: return self._total_fees @property def total_exposure(self) -> float: return sum(p.avg_price * p.size for p in self._positions.values()) @property def position_count(self) -> int: return len(self._positions) @property def trade_count(self) -> int: return self._trade_count @property def win_rate(self) -> float: closed = self._trade_count - len(self._positions) return self._win_count / closed if closed > 0 else 0.0 # ------------------------------------------------------------------ # Risk checks # ------------------------------------------------------------------ def is_exposure_ok(self, additional_usd: float = 0) -> bool: """Check if total exposure is within limits.""" return (self.total_exposure + additional_usd) <= self.config.risk.max_total_exposure_usd def is_daily_loss_ok(self, daily_pnl: float) -> bool: """Check if daily loss is within limits.""" return daily_pnl > -self.config.risk.max_daily_loss_usd def get_summary(self) -> dict: """Return a summary dict for logging/display.""" return { "positions": self.position_count, "total_exposure": round(self.total_exposure, 2), "unrealized_pnl": round(self.total_unrealized_pnl, 2), "realized_pnl": round(self.total_realized_pnl, 2), "total_pnl": round(self.total_pnl, 2), "total_fees": round(self.total_fees, 2), "trades": self.trade_count, "win_rate": round(self.win_rate * 100, 1), }