CI/CD / build-and-push (push) Successful in 29s
- Legacy scan called ManifoldClient.get_probability(), removed in the v3
matcher migration, causing AttributeError when positions had changed
family keys. The block used Manifold to escalate positions to
CLOSE_RECOMMENDED (inversion detection) — a trading decision forbidden
under MANIFOLD_SIGNAL_ENABLED=false — so the dependency is removed
entirely; the scan keeps family re-keying and sibling-conflict logic.
- PaperExecutor.close_position() computed cash += position_cost * resolution,
ignoring direction: a winning BUY_NO (resolution=0.0) paid out $0 and
reported a loss. Now settles per trade:
BUY_YES: payout = shares * resolution
BUY_NO: payout = shares * (1 - resolution)
with pnl = payout - net_cost; Telegram win/loss keys off pnl > 0.
Adds read-only Database.get_open_trades_for_market().
- tests/test_paper_close.py covers the 4 deterministic payout cases;
tests/conftest.py shims datetime.UTC for local Python 3.10 (prod is 3.11).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
284 lines
11 KiB
Python
284 lines
11 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 asyncio
|
|
import logging
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, UTC
|
|
from typing import Optional
|
|
|
|
from bot.risk.manager import Order, Portfolio
|
|
from bot.data.db import Database
|
|
from bot.notify import telegram
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Polymarket taker fee used for paper simulation.
|
|
# Also stored as commission in each Trade for audit purposes.
|
|
# NOTE: this is a heuristic — see COMMISSION_RATE in bayesian.py for context.
|
|
POLYMARKET_FEE = 0.02 # 2%
|
|
|
|
|
|
@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
|
|
# ── Phase 1: edge neto audit fields ──────────────────────────────────────
|
|
# edge_gross: raw model edge before any cost deductions
|
|
# edge_net: edge_gross - spread_estimate - commission/size_usdc
|
|
# Both are heuristic estimates — see schema.sql comment for details.
|
|
edge_gross: float = 0.0
|
|
edge_net: float = 0.0
|
|
prior_prob: float = 0.0 # market.yes_price clamped, before Bayesian update
|
|
final_prob: float = 0.0 # estimated probability after all signals
|
|
# mid_price: order-book midpoint when available; falls back to market.yes_price
|
|
mid_price: float = 0.0
|
|
spread_estimate: float = 0.02
|
|
commission: float = 0.0 # = POLYMARKET_FEE * size_usdc
|
|
# ── Phase 2: market family ────────────────────────────────────────────────
|
|
family_key: str = ""
|
|
# ── Phase 6: per-feature log-odds contributions ───────────────────────────
|
|
feat_fg_lo: float = 0.0
|
|
feat_mom_lo: float = 0.0
|
|
feat_news_lo: float = 0.0
|
|
feat_mfld_lo: float = 0.0
|
|
feat_btc_dom_lo: float = 0.0
|
|
# ── Manifold match audit ──────────────────────────────────────────────────
|
|
mfld_market_id: Optional[str] = None
|
|
mfld_market_title: Optional[str] = None
|
|
mfld_market_url: Optional[str] = None
|
|
mfld_prob_raw: Optional[float] = None
|
|
mfld_prob_final: Optional[float] = None
|
|
mfld_inverted: bool = False
|
|
mfld_match_score: Optional[float] = None
|
|
mfld_match_reason: Optional[str] = None
|
|
mfld_match_status: Optional[str] = None
|
|
|
|
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}) "
|
|
f"edge_net={self.edge_net:+.3f} family={self.family_key} "
|
|
f"| {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.
|
|
|
|
Accounting model (must match execute() exactly):
|
|
positions[market_id] = size_usdc (fee excluded — same as runtime)
|
|
cash = bankroll - sum(net_cost) (fees already spent)
|
|
total_value = cash + sum(size_usdc) = bankroll - sum(fees)
|
|
exposure_pct = sum(size_usdc) / total_value
|
|
"""
|
|
positions_size, total_net_cost = await self._db.get_open_position_data()
|
|
if not positions_size:
|
|
log.info("No open positions in DB — starting with full bankroll")
|
|
return
|
|
|
|
positions_value = sum(positions_size.values())
|
|
self._portfolio.positions = positions_size
|
|
self._portfolio.cash = max(0.0, self._portfolio.cash - total_net_cost)
|
|
|
|
total_value = self._portfolio.cash + positions_value
|
|
exposure_pct = positions_value / total_value if total_value > 0 else 0.0
|
|
log.info(
|
|
"Restored %d open position(s) from DB — "
|
|
"positions_value=$%.2f net_cost_spent=$%.2f cash=$%.2f "
|
|
"total_value=$%.2f exposure=%.2f%%",
|
|
len(positions_size),
|
|
positions_value,
|
|
total_net_cost,
|
|
self._portfolio.cash,
|
|
total_value,
|
|
exposure_pct * 100,
|
|
)
|
|
|
|
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
|
|
|
|
# commission mirrors the heuristic COMMISSION_RATE applied in bayesian.py
|
|
# when computing edge_net. Stored for audit: confirms cost assumption held.
|
|
commission = order.size_usdc * POLYMARKET_FEE # = fee_usdc at current rate
|
|
|
|
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,
|
|
# Phase 1 audit fields
|
|
edge_gross=order.edge_gross,
|
|
edge_net=order.edge_net,
|
|
prior_prob=order.prior_prob,
|
|
final_prob=order.final_prob,
|
|
mid_price=order.mid_price,
|
|
spread_estimate=order.spread_estimate,
|
|
commission=commission,
|
|
# Phase 2 family
|
|
family_key=order.family_key,
|
|
# Phase 6 feature log-odds
|
|
feat_fg_lo=order.feat_fg_lo,
|
|
feat_mom_lo=order.feat_mom_lo,
|
|
feat_news_lo=order.feat_news_lo,
|
|
feat_mfld_lo=order.feat_mfld_lo,
|
|
feat_btc_dom_lo=order.feat_btc_dom_lo,
|
|
# Manifold audit
|
|
mfld_market_id=order.mfld_market_id,
|
|
mfld_market_title=order.mfld_market_title,
|
|
mfld_market_url=order.mfld_market_url,
|
|
mfld_prob_raw=order.mfld_prob_raw,
|
|
mfld_prob_final=order.mfld_prob_final,
|
|
mfld_inverted=order.mfld_inverted,
|
|
mfld_match_score=order.mfld_match_score,
|
|
mfld_match_reason=order.mfld_match_reason,
|
|
mfld_match_status=order.mfld_match_status,
|
|
)
|
|
|
|
# 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)
|
|
|
|
asyncio.create_task(
|
|
telegram.trade_opened(trade.question, trade.direction, trade.size_usdc, trade.edge_net)
|
|
)
|
|
|
|
return trade
|
|
|
|
async def close_legacy_position(self, market_id: str, reason: str, question: str = "") -> float:
|
|
"""
|
|
Close a paper position flagged by the legacy scan.
|
|
|
|
Returns the capital recovered to cash (cost basis, assuming break-even
|
|
exit — exact P&L would require the live exit price which isn't available
|
|
at scan time).
|
|
"""
|
|
cost = self._portfolio.positions.pop(market_id, 0.0)
|
|
self._portfolio.cash += cost # return capital at break-even
|
|
await self._db.close_paper_position(market_id, reason)
|
|
log.warning(
|
|
"LEGACY_CLOSE market=%s | returned $%.2f to cash | %s",
|
|
market_id, cost, reason[:80],
|
|
)
|
|
asyncio.create_task(
|
|
telegram.trade_legacy_closed(question or market_id, cost, reason)
|
|
)
|
|
return cost
|
|
|
|
async def close_position(self, market_id: str, resolution: float, question: str = "") -> Optional[float]:
|
|
"""Close a paper position after market resolution.
|
|
|
|
resolution: 1.0 if YES won, 0.0 if NO won.
|
|
Settlement payout per trade:
|
|
BUY_YES: shares * resolution
|
|
BUY_NO: shares * (1 - resolution)
|
|
pnl = payout - net_cost.
|
|
Persists resolution and close_pnl to DB. Returns realized P&L for
|
|
logging, or None if no position is open.
|
|
"""
|
|
if market_id not in self._portfolio.positions:
|
|
return None
|
|
|
|
position_cost = self._portfolio.positions.pop(market_id)
|
|
open_trades = await self._db.get_open_trades_for_market(market_id)
|
|
|
|
if open_trades:
|
|
payout = sum(
|
|
float(t["shares"])
|
|
* (resolution if t["direction"] == "BUY_YES" else 1.0 - resolution)
|
|
for t in open_trades
|
|
)
|
|
net_cost = sum(float(t["net_cost"]) for t in open_trades)
|
|
pnl = payout - net_cost
|
|
else:
|
|
# In-memory position with no open DB trades: direction/shares are
|
|
# unknown, so settle at break-even instead of guessing the payout.
|
|
log.warning(
|
|
"close_position: no open DB trades for market %s — "
|
|
"settling at break-even", market_id,
|
|
)
|
|
payout = position_cost
|
|
pnl = 0.0
|
|
|
|
self._portfolio.cash += payout
|
|
|
|
await self._db.close_paper_position(
|
|
market_id,
|
|
reason=f"market_resolved resolution={resolution:.1f}",
|
|
resolution=resolution,
|
|
)
|
|
log.info(
|
|
"Closed position in %s, resolution=%.1f payout=$%.2f pnl=%+.2f",
|
|
market_id, resolution, payout, pnl,
|
|
)
|
|
asyncio.create_task(
|
|
telegram.trade_closed(question or market_id, pnl)
|
|
)
|
|
return pnl
|