212 lines
6.7 KiB
Python
212 lines
6.7 KiB
Python
|
|
"""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),
|
||
|
|
}
|