143 lines
4.7 KiB
Python
143 lines
4.7 KiB
Python
"""
|
|
Paper Trading Executor — simulates order execution without real money.
|
|
|
|
Simulates realistic slippage and fees to get accurate paper P&L.
|
|
All trades are logged to PostgreSQL for metrics analysis.
|
|
"""
|
|
import logging
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, UTC
|
|
from typing import Optional
|
|
|
|
from bot.risk.manager import Order, Portfolio
|
|
from bot.data.db import Database
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
POLYMARKET_FEE = 0.02 # 2% fee on each trade
|
|
|
|
|
|
@dataclass
|
|
class Trade:
|
|
id: str
|
|
market_id: str
|
|
question: str
|
|
direction: str
|
|
size_usdc: float
|
|
entry_price: float
|
|
shares: float # How many YES/NO shares bought
|
|
fee_usdc: float
|
|
net_cost: float
|
|
timestamp: datetime
|
|
reasoning: str
|
|
paper: bool = True
|
|
|
|
def __str__(self) -> str:
|
|
return (
|
|
f"[PAPER] {self.direction} {self.shares:.1f} shares @ {self.entry_price:.3f} "
|
|
f"= ${self.net_cost:.2f} (fee ${self.fee_usdc:.2f}) | {self.question[:40]}"
|
|
)
|
|
|
|
|
|
class PaperExecutor:
|
|
"""Executes trades on paper — no real money, realistic simulation."""
|
|
|
|
def __init__(self, db: Database, bankroll: float) -> None:
|
|
self._db = db
|
|
self._portfolio = Portfolio(
|
|
cash=bankroll,
|
|
positions={},
|
|
)
|
|
log.info("Paper executor initialized with $%.2f bankroll", bankroll)
|
|
|
|
async def initialize(self) -> None:
|
|
"""Reconcile in-memory portfolio with DB state.
|
|
|
|
Called once after __init__ so the executor reflects any trades that
|
|
survived a pod restart. After a TRUNCATE the DB is empty and the
|
|
portfolio resets to a full bankroll automatically.
|
|
"""
|
|
positions = await self._db.get_open_positions()
|
|
if not positions:
|
|
log.info("No open positions in DB — starting with full bankroll")
|
|
return
|
|
|
|
total_deployed = sum(positions.values())
|
|
self._portfolio.positions = positions
|
|
self._portfolio.cash = max(0.0, self._portfolio.cash - total_deployed)
|
|
log.info(
|
|
"Restored %d open position(s) from DB — deployed $%.2f, cash $%.2f",
|
|
len(positions),
|
|
total_deployed,
|
|
self._portfolio.cash,
|
|
)
|
|
|
|
def get_portfolio(self) -> Portfolio:
|
|
return self._portfolio
|
|
|
|
async def execute(self, order: Order) -> Optional[Trade]:
|
|
"""Simulate order execution with fees and slippage."""
|
|
if order.size_usdc > self._portfolio.cash:
|
|
log.warning(
|
|
"Insufficient paper cash: need $%.2f have $%.2f",
|
|
order.size_usdc,
|
|
self._portfolio.cash,
|
|
)
|
|
return None
|
|
|
|
# Simulate small slippage (0.1-0.3% depending on order size)
|
|
slippage = min(0.003, order.size_usdc / 100000)
|
|
|
|
# Determine entry price based on direction.
|
|
# We fill at the current Polymarket mid price + slippage (buying at ask).
|
|
# BUY_YES → paying the YES price (order.market_price)
|
|
# BUY_NO → paying the NO price (1 - order.market_price)
|
|
if order.direction == "BUY_YES":
|
|
entry_price = min(0.99, order.market_price + slippage)
|
|
else:
|
|
entry_price = min(0.99, (1 - order.market_price) + slippage)
|
|
|
|
fee = order.size_usdc * POLYMARKET_FEE
|
|
net_cost = order.size_usdc + fee
|
|
shares = order.size_usdc / entry_price
|
|
|
|
trade = Trade(
|
|
id=str(uuid.uuid4()),
|
|
market_id=order.market_id,
|
|
question=order.question,
|
|
direction=order.direction,
|
|
size_usdc=order.size_usdc,
|
|
entry_price=entry_price,
|
|
shares=shares,
|
|
fee_usdc=fee,
|
|
net_cost=net_cost,
|
|
timestamp=datetime.now(UTC),
|
|
reasoning=order.reasoning,
|
|
paper=True,
|
|
)
|
|
|
|
# Update paper portfolio
|
|
self._portfolio.cash -= net_cost
|
|
self._portfolio.positions[order.market_id] = order.size_usdc
|
|
|
|
# Persist to DB
|
|
await self._db.save_trade(trade)
|
|
|
|
return trade
|
|
|
|
async def close_position(self, market_id: str, resolution: float) -> Optional[float]:
|
|
"""
|
|
Close a paper position after market resolution.
|
|
resolution: 1.0 if YES won, 0.0 if NO won.
|
|
Returns P&L in USDC.
|
|
"""
|
|
if market_id not in self._portfolio.positions:
|
|
return None
|
|
|
|
# This would be called by a settlement watcher (future feature)
|
|
# For now, positions auto-expire at market end date
|
|
position_cost = self._portfolio.positions.pop(market_id)
|
|
log.info("Closed position in %s, resolution=%.0f", market_id, resolution)
|
|
return position_cost * resolution - position_cost
|