update 03-22 09:28

This commit is contained in:
2026-03-22 09:28:14 +09:00
commit 7f45211276
43 changed files with 9373 additions and 0 deletions

View File

@@ -0,0 +1,211 @@
"""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),
}