902 lines
31 KiB
Python
902 lines
31 KiB
Python
|
|
"""Paper trading wrapper for the Polymarket Temporal Arbitrage Bot.
|
||
|
|
|
||
|
|
Runs the same event loop as ``src.main`` but forces paper-trading mode,
|
||
|
|
adds a simulated order execution layer that tracks virtual positions
|
||
|
|
and PnL, and prints periodic performance summaries.
|
||
|
|
|
||
|
|
Usage::
|
||
|
|
|
||
|
|
python paper_trade.py
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import signal
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
import uuid
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
import structlog
|
||
|
|
|
||
|
|
from src.config import (
|
||
|
|
BinanceConfig,
|
||
|
|
Config,
|
||
|
|
FeesConfig,
|
||
|
|
GeneralConfig,
|
||
|
|
NotificationConfig,
|
||
|
|
PolymarketConfig,
|
||
|
|
RiskConfig,
|
||
|
|
SpreadCaptureConfig,
|
||
|
|
SumToOneConfig,
|
||
|
|
TemporalArbConfig,
|
||
|
|
load_config,
|
||
|
|
)
|
||
|
|
from src.data import TradeDB
|
||
|
|
from src.data.models import (
|
||
|
|
ActiveMarket,
|
||
|
|
Asset,
|
||
|
|
Direction,
|
||
|
|
OrderBookSnapshot,
|
||
|
|
Signal,
|
||
|
|
Timeframe,
|
||
|
|
Trade,
|
||
|
|
TradeStatus,
|
||
|
|
WindowState,
|
||
|
|
)
|
||
|
|
from src.feeds import BinanceFeed, PolymarketFeed
|
||
|
|
from src.market import MarketDiscovery, WindowTracker
|
||
|
|
from src.market.oracle import OracleMonitor
|
||
|
|
from src.strategy import SignalAggregator
|
||
|
|
from src.utils import get_logger, setup_logging
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Constants
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
_STATUS_INTERVAL_S: float = 30.0
|
||
|
|
_SUMMARY_INTERVAL_S: float = 300.0 # 5 minutes
|
||
|
|
_DISCOVERY_INTERVAL_S: float = 30.0
|
||
|
|
|
||
|
|
_BANNER = r"""
|
||
|
|
____ _____ _
|
||
|
|
| _ \ __ _ _ __ ___ _ __ |_ _| __ __ _ __| | ___
|
||
|
|
| |_) / _` | '_ \ / _ \ '__| | || '__/ _` |/ _` |/ _ \
|
||
|
|
| __/ (_| | |_) | __/ | | || | | (_| | (_| | __/
|
||
|
|
|_| \__,_| .__/ \___|_| |_||_| \__,_|\__,_|\___|
|
||
|
|
|_|
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Virtual position tracking
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class VirtualPosition:
|
||
|
|
"""A simulated open position."""
|
||
|
|
|
||
|
|
position_id: str
|
||
|
|
asset: Asset
|
||
|
|
timeframe: Timeframe
|
||
|
|
direction: Direction
|
||
|
|
token_id: str
|
||
|
|
entry_price: float
|
||
|
|
size: int
|
||
|
|
opened_at: float = field(default_factory=time.time)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class VirtualTradeRecord:
|
||
|
|
"""A completed (closed) virtual trade."""
|
||
|
|
|
||
|
|
trade_id: str
|
||
|
|
asset: Asset
|
||
|
|
timeframe: Timeframe
|
||
|
|
direction: Direction
|
||
|
|
token_id: str
|
||
|
|
entry_price: float
|
||
|
|
exit_price: float
|
||
|
|
size: int
|
||
|
|
fee: float
|
||
|
|
pnl: float
|
||
|
|
opened_at: float
|
||
|
|
closed_at: float = field(default_factory=time.time)
|
||
|
|
|
||
|
|
|
||
|
|
class PaperExecutionEngine:
|
||
|
|
"""Simulated order execution that tracks virtual positions and PnL.
|
||
|
|
|
||
|
|
Accepts trade signals, creates virtual positions, and resolves them
|
||
|
|
when the window expires. All trades are logged to SQLite via
|
||
|
|
:class:`TradeDB`.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, db: TradeDB, fees: FeesConfig) -> None:
|
||
|
|
self._db = db
|
||
|
|
self._fees = fees
|
||
|
|
self._log = get_logger("paper_engine")
|
||
|
|
self._positions: dict[str, VirtualPosition] = {}
|
||
|
|
self._closed_trades: list[VirtualTradeRecord] = []
|
||
|
|
self._total_pnl: float = 0.0
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# Public API
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
|
||
|
|
@property
|
||
|
|
def active_positions(self) -> list[VirtualPosition]:
|
||
|
|
return list(self._positions.values())
|
||
|
|
|
||
|
|
@property
|
||
|
|
def total_pnl(self) -> float:
|
||
|
|
return self._total_pnl
|
||
|
|
|
||
|
|
@property
|
||
|
|
def trade_count(self) -> int:
|
||
|
|
return len(self._closed_trades)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def win_rate(self) -> float:
|
||
|
|
if not self._closed_trades:
|
||
|
|
return 0.0
|
||
|
|
wins = sum(1 for t in self._closed_trades if t.pnl > 0)
|
||
|
|
return wins / len(self._closed_trades)
|
||
|
|
|
||
|
|
def open_position(
|
||
|
|
self,
|
||
|
|
asset: Asset,
|
||
|
|
timeframe: Timeframe,
|
||
|
|
direction: Direction,
|
||
|
|
token_id: str,
|
||
|
|
entry_price: float,
|
||
|
|
size: int,
|
||
|
|
edge: float,
|
||
|
|
estimated_prob: float,
|
||
|
|
) -> str:
|
||
|
|
"""Simulate opening a position. Returns the position ID."""
|
||
|
|
position_id = uuid.uuid4().hex[:12]
|
||
|
|
pos = VirtualPosition(
|
||
|
|
position_id=position_id,
|
||
|
|
asset=asset,
|
||
|
|
timeframe=timeframe,
|
||
|
|
direction=direction,
|
||
|
|
token_id=token_id,
|
||
|
|
entry_price=entry_price,
|
||
|
|
size=size,
|
||
|
|
)
|
||
|
|
self._positions[position_id] = pos
|
||
|
|
|
||
|
|
# Log the pending trade to DB
|
||
|
|
sig = Signal(
|
||
|
|
direction=direction,
|
||
|
|
asset=asset,
|
||
|
|
timeframe=timeframe,
|
||
|
|
token_id=token_id,
|
||
|
|
price=entry_price,
|
||
|
|
size=size,
|
||
|
|
edge=edge,
|
||
|
|
estimated_prob=estimated_prob,
|
||
|
|
)
|
||
|
|
trade = Trade(
|
||
|
|
id=f"paper-{position_id}",
|
||
|
|
signal=sig,
|
||
|
|
status=TradeStatus.FILLED,
|
||
|
|
fill_price=entry_price,
|
||
|
|
fill_size=size,
|
||
|
|
fee=0.0,
|
||
|
|
pnl=0.0,
|
||
|
|
)
|
||
|
|
self._db.log_trade(trade)
|
||
|
|
|
||
|
|
self._log.info(
|
||
|
|
"virtual_position_opened",
|
||
|
|
position_id=position_id,
|
||
|
|
asset=asset.value,
|
||
|
|
timeframe=timeframe.value,
|
||
|
|
direction=direction.value,
|
||
|
|
entry_price=entry_price,
|
||
|
|
size=size,
|
||
|
|
)
|
||
|
|
return position_id
|
||
|
|
|
||
|
|
def close_position(self, position_id: str, exit_price: float) -> Optional[VirtualTradeRecord]:
|
||
|
|
"""Simulate closing a position at *exit_price*. Returns the trade record."""
|
||
|
|
pos = self._positions.pop(position_id, None)
|
||
|
|
if pos is None:
|
||
|
|
self._log.warning("close_unknown_position", position_id=position_id)
|
||
|
|
return None
|
||
|
|
|
||
|
|
# Calculate fee based on timeframe
|
||
|
|
fee_rate = self._fees.fee_for_timeframe(pos.timeframe.value)
|
||
|
|
notional = pos.size * pos.entry_price
|
||
|
|
fee = notional * fee_rate
|
||
|
|
|
||
|
|
# PnL: if direction is UP and price went up, the binary resolves to 1
|
||
|
|
# Simplified: pnl = size * (exit_price - entry_price) - fee
|
||
|
|
pnl = pos.size * (exit_price - pos.entry_price) - fee
|
||
|
|
|
||
|
|
record = VirtualTradeRecord(
|
||
|
|
trade_id=f"paper-{pos.position_id}",
|
||
|
|
asset=pos.asset,
|
||
|
|
timeframe=pos.timeframe,
|
||
|
|
direction=pos.direction,
|
||
|
|
token_id=pos.token_id,
|
||
|
|
entry_price=pos.entry_price,
|
||
|
|
exit_price=exit_price,
|
||
|
|
size=pos.size,
|
||
|
|
fee=fee,
|
||
|
|
pnl=pnl,
|
||
|
|
opened_at=pos.opened_at,
|
||
|
|
)
|
||
|
|
self._closed_trades.append(record)
|
||
|
|
self._total_pnl += pnl
|
||
|
|
|
||
|
|
# Update the trade record in DB
|
||
|
|
self._db.update_trade(
|
||
|
|
record.trade_id,
|
||
|
|
fill_price=exit_price,
|
||
|
|
fee=fee,
|
||
|
|
pnl=pnl,
|
||
|
|
status=TradeStatus.FILLED.value,
|
||
|
|
)
|
||
|
|
|
||
|
|
self._log.info(
|
||
|
|
"virtual_position_closed",
|
||
|
|
position_id=pos.position_id,
|
||
|
|
asset=pos.asset.value,
|
||
|
|
direction=pos.direction.value,
|
||
|
|
entry=pos.entry_price,
|
||
|
|
exit=exit_price,
|
||
|
|
pnl=round(pnl, 4),
|
||
|
|
fee=round(fee, 4),
|
||
|
|
total_pnl=round(self._total_pnl, 4),
|
||
|
|
)
|
||
|
|
return record
|
||
|
|
|
||
|
|
def print_summary(self) -> None:
|
||
|
|
"""Print a performance summary to the log."""
|
||
|
|
self._log.info(
|
||
|
|
"paper_trade_summary",
|
||
|
|
total_pnl=round(self._total_pnl, 4),
|
||
|
|
trade_count=self.trade_count,
|
||
|
|
win_rate=round(self.win_rate * 100, 1),
|
||
|
|
active_positions=len(self._positions),
|
||
|
|
positions=[
|
||
|
|
{
|
||
|
|
"id": p.position_id,
|
||
|
|
"asset": p.asset.value,
|
||
|
|
"dir": p.direction.value,
|
||
|
|
"entry": p.entry_price,
|
||
|
|
"size": p.size,
|
||
|
|
}
|
||
|
|
for p in self._positions.values()
|
||
|
|
],
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Paper trading bot
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class PaperTradingBot:
|
||
|
|
"""Orchestrates Phase 1 components with a paper execution layer."""
|
||
|
|
|
||
|
|
def __init__(self, config: Config) -> None:
|
||
|
|
self._cfg = config
|
||
|
|
self._log = get_logger("paper_bot")
|
||
|
|
self._starting_balance = config.general.starting_balance
|
||
|
|
self._balance = config.general.starting_balance
|
||
|
|
|
||
|
|
# Components
|
||
|
|
self._db: Optional[TradeDB] = None
|
||
|
|
self._engine: Optional[PaperExecutionEngine] = None
|
||
|
|
self._discovery: Optional[MarketDiscovery] = None
|
||
|
|
self._tracker: Optional[WindowTracker] = None
|
||
|
|
self._binance_feed: Optional[BinanceFeed] = None
|
||
|
|
self._poly_feed: Optional[PolymarketFeed] = None
|
||
|
|
self._signal_agg: Optional[SignalAggregator] = None
|
||
|
|
self._oracle: Optional[OracleMonitor] = None
|
||
|
|
|
||
|
|
# Discovered markets cache
|
||
|
|
self._active_markets: list[ActiveMarket] = []
|
||
|
|
|
||
|
|
# Live orderbook state (token_id → snapshot)
|
||
|
|
self._orderbooks: dict[str, OrderBookSnapshot] = {}
|
||
|
|
|
||
|
|
# Pending strategy evaluations (initialized in start())
|
||
|
|
self._eval_queue: Optional[asyncio.Queue] = None
|
||
|
|
|
||
|
|
# Shutdown coordination
|
||
|
|
self._shutdown_event = asyncio.Event()
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# Callbacks
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
|
||
|
|
def _on_binance_price(
|
||
|
|
self, symbol: str, price: float, timestamp: float, volume: float
|
||
|
|
) -> None:
|
||
|
|
"""Process each Binance trade tick — also triggers strategy evaluation."""
|
||
|
|
self._log.debug(
|
||
|
|
"binance_price",
|
||
|
|
symbol=symbol,
|
||
|
|
price=price,
|
||
|
|
volume=volume,
|
||
|
|
)
|
||
|
|
if self._tracker is not None:
|
||
|
|
self._tracker.update_price(symbol, price, timestamp)
|
||
|
|
|
||
|
|
# Trigger strategy evaluation for each timeframe
|
||
|
|
if self._signal_agg is None or self._tracker is None:
|
||
|
|
return
|
||
|
|
for tf_str in self._cfg.general.timeframes:
|
||
|
|
window = self._tracker.get_window(symbol, tf_str)
|
||
|
|
if window is None or window.start_price is None:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# If no real Polymarket orderbooks, simulate them every tick
|
||
|
|
orderbooks = self._orderbooks
|
||
|
|
is_simulated = window.market is not None and window.market.condition_id.startswith("sim_")
|
||
|
|
if window.market is None or is_simulated:
|
||
|
|
orderbooks = self._simulate_orderbooks(symbol, price, window)
|
||
|
|
|
||
|
|
# Now window.market is guaranteed to be set
|
||
|
|
if window.market is not None and self._eval_queue is not None:
|
||
|
|
try:
|
||
|
|
self._eval_queue.put_nowait((symbol, price, window, orderbooks))
|
||
|
|
except (asyncio.QueueFull, Exception):
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _on_orderbook_update(
|
||
|
|
self, token_id: str, snapshot: OrderBookSnapshot
|
||
|
|
) -> None:
|
||
|
|
"""Process each Polymarket orderbook update — store for strategy use."""
|
||
|
|
self._orderbooks[token_id] = snapshot
|
||
|
|
self._log.debug(
|
||
|
|
"poly_orderbook",
|
||
|
|
token_id=token_id[:12],
|
||
|
|
best_bid=snapshot.best_bid,
|
||
|
|
best_ask=snapshot.best_ask,
|
||
|
|
)
|
||
|
|
|
||
|
|
def _on_window_change(self, window: WindowState) -> None:
|
||
|
|
"""Fired when a price window transitions.
|
||
|
|
|
||
|
|
*window* is the COMPLETED window (with final current_price and
|
||
|
|
price_change_pct). Resolve open positions and snapshot to DB.
|
||
|
|
"""
|
||
|
|
self._log.info(
|
||
|
|
"window_completed",
|
||
|
|
asset=window.asset.value,
|
||
|
|
timeframe=window.timeframe.value,
|
||
|
|
start_price=window.start_price,
|
||
|
|
end_price=window.current_price,
|
||
|
|
change_pct=window.price_change_pct,
|
||
|
|
window_start=window.window_start_time,
|
||
|
|
window_end=window.window_end_time,
|
||
|
|
)
|
||
|
|
# Resolve positions from the completed window
|
||
|
|
self._on_window_change_resolve(window)
|
||
|
|
|
||
|
|
# Snapshot completed window to DB
|
||
|
|
if self._db is not None and window.start_price is not None:
|
||
|
|
try:
|
||
|
|
self._db.log_window(window)
|
||
|
|
except Exception:
|
||
|
|
self._log.exception("window_snapshot_failed")
|
||
|
|
|
||
|
|
def _on_signal(self, signal: Signal) -> None:
|
||
|
|
"""Handle a trading signal from the strategy engine — open a virtual position."""
|
||
|
|
if self._engine is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
# Check balance
|
||
|
|
cost = signal.price * signal.size
|
||
|
|
if cost > self._balance * 0.5: # Don't risk more than 50% on one trade
|
||
|
|
self._log.warning("signal_rejected_balance", cost=round(cost, 2), balance=round(self._balance, 2))
|
||
|
|
return
|
||
|
|
|
||
|
|
position_id = self._engine.open_position(
|
||
|
|
asset=signal.asset,
|
||
|
|
timeframe=signal.timeframe,
|
||
|
|
direction=signal.direction,
|
||
|
|
token_id=signal.token_id,
|
||
|
|
entry_price=signal.price,
|
||
|
|
size=signal.size,
|
||
|
|
edge=signal.edge,
|
||
|
|
estimated_prob=signal.estimated_prob,
|
||
|
|
)
|
||
|
|
|
||
|
|
self._log.info(
|
||
|
|
"paper_trade_opened",
|
||
|
|
position_id=position_id,
|
||
|
|
asset=signal.asset.value,
|
||
|
|
direction=signal.direction.value,
|
||
|
|
price=signal.price,
|
||
|
|
size=signal.size,
|
||
|
|
edge=round(signal.edge, 4),
|
||
|
|
)
|
||
|
|
|
||
|
|
def _on_window_change_resolve(self, window: WindowState) -> None:
|
||
|
|
"""When a window changes, resolve any open positions from the PREVIOUS window.
|
||
|
|
|
||
|
|
If the price went UP and we held UP, we win ($1 payout).
|
||
|
|
If the price went DOWN and we held DOWN, we win ($1 payout).
|
||
|
|
Otherwise, we lose ($0 payout).
|
||
|
|
"""
|
||
|
|
if self._engine is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
# Check if any active positions match this asset+timeframe
|
||
|
|
for pos in list(self._engine.active_positions):
|
||
|
|
if pos.asset == window.asset and pos.timeframe == window.timeframe:
|
||
|
|
# Determine outcome from the COMPLETED window's price change
|
||
|
|
if window.price_change_pct is not None:
|
||
|
|
actual_up = window.price_change_pct > 0
|
||
|
|
position_won = (
|
||
|
|
(pos.direction == Direction.UP and actual_up) or
|
||
|
|
(pos.direction == Direction.DOWN and not actual_up)
|
||
|
|
)
|
||
|
|
exit_price = 1.0 if position_won else 0.0
|
||
|
|
else:
|
||
|
|
# No price data — treat as loss
|
||
|
|
exit_price = 0.0
|
||
|
|
|
||
|
|
record = self._engine.close_position(pos.position_id, exit_price)
|
||
|
|
if record:
|
||
|
|
# Update balance
|
||
|
|
self._balance = self._starting_balance + self._engine.total_pnl
|
||
|
|
self._log.info(
|
||
|
|
"paper_trade_resolved",
|
||
|
|
asset=pos.asset.value,
|
||
|
|
direction=pos.direction.value,
|
||
|
|
pnl=round(record.pnl, 4),
|
||
|
|
balance=round(self._balance, 2),
|
||
|
|
won=record.pnl > 0,
|
||
|
|
)
|
||
|
|
|
||
|
|
def _simulate_orderbooks(
|
||
|
|
self, symbol: str, cex_price: float, window: WindowState
|
||
|
|
) -> dict[str, OrderBookSnapshot]:
|
||
|
|
"""Generate simulated Polymarket orderbooks when no real market exists.
|
||
|
|
|
||
|
|
Simulates the oracle-lag effect: Polymarket odds lag behind the CEX
|
||
|
|
price. A bigger price move = higher true prob, but the simulated
|
||
|
|
Polymarket price adjusts more slowly.
|
||
|
|
"""
|
||
|
|
import random
|
||
|
|
from src.data.models import OrderBookLevel
|
||
|
|
|
||
|
|
if window.start_price is None or window.start_price <= 0:
|
||
|
|
return {}
|
||
|
|
|
||
|
|
change_pct = (cex_price - window.start_price) / window.start_price * 100
|
||
|
|
|
||
|
|
# Simulate Polymarket's lagging price
|
||
|
|
# Real prob might be 85%, but Polymarket shows ~55-60% (the arb opportunity)
|
||
|
|
lag_factor = 0.35 # Polymarket adjusts at ~35% of true movement speed
|
||
|
|
noise = random.uniform(-0.02, 0.02)
|
||
|
|
|
||
|
|
if abs(change_pct) < 0.05:
|
||
|
|
# Flat — both sides near 50%
|
||
|
|
up_ask = 0.50 + noise
|
||
|
|
down_ask = 0.50 - noise
|
||
|
|
else:
|
||
|
|
# Up direction confirmed on CEX
|
||
|
|
# True prob: ~70-90%, but Polymarket shows ~52-65%
|
||
|
|
poly_adjustment = abs(change_pct) * lag_factor
|
||
|
|
if change_pct > 0:
|
||
|
|
up_ask = min(0.65, 0.50 + poly_adjustment + noise)
|
||
|
|
down_ask = max(0.35, 1.0 - up_ask - 0.02)
|
||
|
|
else:
|
||
|
|
down_ask = min(0.65, 0.50 + poly_adjustment + noise)
|
||
|
|
up_ask = max(0.35, 1.0 - down_ask - 0.02)
|
||
|
|
|
||
|
|
up_ask = round(max(0.01, min(0.99, up_ask)), 2)
|
||
|
|
down_ask = round(max(0.01, min(0.99, down_ask)), 2)
|
||
|
|
|
||
|
|
# Create synthetic token IDs
|
||
|
|
up_token = f"sim_up_{symbol}_{window.timeframe.value}"
|
||
|
|
down_token = f"sim_down_{symbol}_{window.timeframe.value}"
|
||
|
|
|
||
|
|
# Ensure window has a simulated market for signal generation
|
||
|
|
if window.market is None:
|
||
|
|
sim_market = ActiveMarket(
|
||
|
|
condition_id=f"sim_{symbol}_{window.timeframe.value}",
|
||
|
|
up_token_id=up_token,
|
||
|
|
down_token_id=down_token,
|
||
|
|
asset=window.asset,
|
||
|
|
timeframe=window.timeframe,
|
||
|
|
end_date="",
|
||
|
|
question=f"Simulated {symbol} {window.timeframe.value}",
|
||
|
|
)
|
||
|
|
window.market = sim_market
|
||
|
|
|
||
|
|
return {
|
||
|
|
up_token: OrderBookSnapshot(
|
||
|
|
token_id=up_token,
|
||
|
|
asks=[OrderBookLevel(price=up_ask, size=10000)],
|
||
|
|
bids=[OrderBookLevel(price=up_ask - 0.02, size=10000)],
|
||
|
|
),
|
||
|
|
down_token: OrderBookSnapshot(
|
||
|
|
token_id=down_token,
|
||
|
|
asks=[OrderBookLevel(price=down_ask, size=10000)],
|
||
|
|
bids=[OrderBookLevel(price=down_ask - 0.02, size=10000)],
|
||
|
|
),
|
||
|
|
}
|
||
|
|
|
||
|
|
def _on_new_markets(self, markets: list[ActiveMarket]) -> None:
|
||
|
|
"""Callback when MarketDiscovery finds new markets."""
|
||
|
|
self._active_markets.extend(markets)
|
||
|
|
if self._tracker is not None:
|
||
|
|
for mkt in markets:
|
||
|
|
self._tracker.link_market(
|
||
|
|
asset=mkt.asset.value,
|
||
|
|
timeframe=mkt.timeframe.value,
|
||
|
|
market=mkt,
|
||
|
|
)
|
||
|
|
if self._poly_feed is not None:
|
||
|
|
for mkt in markets:
|
||
|
|
self._poly_feed.subscribe_market(mkt.up_token_id)
|
||
|
|
self._poly_feed.subscribe_market(mkt.down_token_id)
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# Status and summary loops
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
|
||
|
|
async def _strategy_eval_loop(self) -> None:
|
||
|
|
"""Consume price ticks from the queue and run strategy evaluations."""
|
||
|
|
eval_count = 0
|
||
|
|
while not self._shutdown_event.is_set():
|
||
|
|
try:
|
||
|
|
symbol, price, window, orderbooks = await asyncio.wait_for(
|
||
|
|
self._eval_queue.get(), timeout=1.0
|
||
|
|
)
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
continue
|
||
|
|
except Exception:
|
||
|
|
continue
|
||
|
|
|
||
|
|
eval_count += 1
|
||
|
|
if eval_count % 100 == 1:
|
||
|
|
self._log.info(
|
||
|
|
"strategy_eval_tick",
|
||
|
|
eval_count=eval_count,
|
||
|
|
symbol=symbol,
|
||
|
|
price=price,
|
||
|
|
change_pct=round(window.price_change_pct or 0, 4),
|
||
|
|
has_market=window.market is not None,
|
||
|
|
orderbook_tokens=list(orderbooks.keys())[:2],
|
||
|
|
)
|
||
|
|
|
||
|
|
if self._signal_agg is not None:
|
||
|
|
try:
|
||
|
|
await self._signal_agg.on_price_tick(
|
||
|
|
symbol=symbol,
|
||
|
|
cex_price=price,
|
||
|
|
window=window,
|
||
|
|
orderbooks=orderbooks,
|
||
|
|
)
|
||
|
|
except Exception:
|
||
|
|
self._log.exception("strategy_eval_error")
|
||
|
|
|
||
|
|
async def _status_loop(self) -> None:
|
||
|
|
"""Log a status summary every ``_STATUS_INTERVAL_S`` seconds."""
|
||
|
|
while not self._shutdown_event.is_set():
|
||
|
|
try:
|
||
|
|
await asyncio.wait_for(
|
||
|
|
self._shutdown_event.wait(),
|
||
|
|
timeout=_STATUS_INTERVAL_S,
|
||
|
|
)
|
||
|
|
break
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
windows = (
|
||
|
|
self._tracker.get_all_active_windows()
|
||
|
|
if self._tracker
|
||
|
|
else []
|
||
|
|
)
|
||
|
|
|
||
|
|
latest_prices: dict[str, Optional[float]] = {}
|
||
|
|
if self._binance_feed is not None:
|
||
|
|
for sym in self._cfg.general.assets:
|
||
|
|
latest_prices[sym] = self._binance_feed.get_latest_price(sym)
|
||
|
|
|
||
|
|
# Update balance from PnL
|
||
|
|
pnl = self._engine.total_pnl if self._engine else 0.0
|
||
|
|
self._balance = self._starting_balance + pnl
|
||
|
|
|
||
|
|
# Record balance snapshot to DB
|
||
|
|
if self._db is not None:
|
||
|
|
self._db.log_balance(self._balance, pnl, event="periodic")
|
||
|
|
|
||
|
|
self._log.info(
|
||
|
|
"status",
|
||
|
|
mode="paper",
|
||
|
|
balance=round(self._balance, 2),
|
||
|
|
pnl=round(pnl, 2),
|
||
|
|
binance_connected=(
|
||
|
|
self._binance_feed.is_connected
|
||
|
|
if self._binance_feed
|
||
|
|
else False
|
||
|
|
),
|
||
|
|
polymarket_connected=(
|
||
|
|
self._poly_feed.is_connected
|
||
|
|
if self._poly_feed
|
||
|
|
else False
|
||
|
|
),
|
||
|
|
active_windows=len(windows),
|
||
|
|
active_markets=len(self._active_markets),
|
||
|
|
latest_prices=latest_prices,
|
||
|
|
)
|
||
|
|
|
||
|
|
async def _oracle_snapshot_loop(self) -> None:
|
||
|
|
"""Log oracle vs CEX deviation to DB every 30 seconds."""
|
||
|
|
while not self._shutdown_event.is_set():
|
||
|
|
try:
|
||
|
|
await asyncio.wait_for(
|
||
|
|
self._shutdown_event.wait(), timeout=30.0,
|
||
|
|
)
|
||
|
|
break
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
if self._oracle is None or self._binance_feed is None or self._db is None:
|
||
|
|
continue
|
||
|
|
|
||
|
|
for asset in self._cfg.general.assets:
|
||
|
|
cex_price = self._binance_feed.get_latest_price(asset)
|
||
|
|
oracle_price = self._oracle._last_oracle_prices.get(asset)
|
||
|
|
if cex_price is None or oracle_price is None:
|
||
|
|
continue
|
||
|
|
|
||
|
|
deviation = self._oracle.get_oracle_vs_cex_deviation(asset, cex_price)
|
||
|
|
lag = self._oracle.get_estimated_lag(asset)
|
||
|
|
round_id = self._oracle._last_oracle_round_ids.get(asset, 0)
|
||
|
|
|
||
|
|
if deviation is not None and lag is not None:
|
||
|
|
self._db.log_oracle(
|
||
|
|
asset=asset,
|
||
|
|
oracle_price=oracle_price,
|
||
|
|
cex_price=cex_price,
|
||
|
|
deviation_pct=deviation,
|
||
|
|
oracle_lag_sec=lag,
|
||
|
|
oracle_round_id=round_id,
|
||
|
|
)
|
||
|
|
|
||
|
|
self._log.info(
|
||
|
|
"oracle_snapshot",
|
||
|
|
stats=self._oracle.get_stats(),
|
||
|
|
)
|
||
|
|
|
||
|
|
async def _summary_loop(self) -> None:
|
||
|
|
"""Print paper trading summary every ``_SUMMARY_INTERVAL_S`` seconds."""
|
||
|
|
while not self._shutdown_event.is_set():
|
||
|
|
try:
|
||
|
|
await asyncio.wait_for(
|
||
|
|
self._shutdown_event.wait(),
|
||
|
|
timeout=_SUMMARY_INTERVAL_S,
|
||
|
|
)
|
||
|
|
break
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
if self._engine is not None:
|
||
|
|
self._engine.print_summary()
|
||
|
|
|
||
|
|
if self._db is not None:
|
||
|
|
self._log.info(
|
||
|
|
"db_summary",
|
||
|
|
today_trades=self._db.get_today_trade_count(),
|
||
|
|
today_pnl=round(self._db.get_today_pnl(), 4),
|
||
|
|
total_pnl=round(self._db.get_total_pnl(), 4),
|
||
|
|
)
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
# Lifecycle
|
||
|
|
# ------------------------------------------------------------------
|
||
|
|
|
||
|
|
async def start(self) -> None:
|
||
|
|
"""Initialise all components, then run feeds concurrently."""
|
||
|
|
self._log.info("paper_bot_initialising")
|
||
|
|
|
||
|
|
# 0. Initialize eval queue (must be in async context)
|
||
|
|
self._eval_queue = asyncio.Queue(maxsize=1000)
|
||
|
|
|
||
|
|
# 1. Database (paper-specific path)
|
||
|
|
self._db = TradeDB(db_path="paper_trades.db")
|
||
|
|
|
||
|
|
# 2. Paper execution engine
|
||
|
|
self._engine = PaperExecutionEngine(
|
||
|
|
db=self._db, fees=self._cfg.fees,
|
||
|
|
)
|
||
|
|
|
||
|
|
# Record starting balance
|
||
|
|
self._db.log_balance(self._balance, 0.0, event="start")
|
||
|
|
self._log.info("starting_balance", balance=self._balance)
|
||
|
|
|
||
|
|
# 2b. Signal aggregator (strategy engine)
|
||
|
|
self._signal_agg = SignalAggregator(
|
||
|
|
config=self._cfg, balance=self._balance,
|
||
|
|
)
|
||
|
|
self._signal_agg.on_signal(self._on_signal)
|
||
|
|
self._log.info("strategy_engine_ready",
|
||
|
|
temporal_arb=self._cfg.temporal_arb.enabled,
|
||
|
|
sum_to_one=self._cfg.sum_to_one.enabled)
|
||
|
|
|
||
|
|
# 3. Market discovery
|
||
|
|
self._discovery = MarketDiscovery(
|
||
|
|
on_new_markets=self._on_new_markets,
|
||
|
|
)
|
||
|
|
self._log.info("running_initial_discovery")
|
||
|
|
initial_markets = await self._discovery.discover()
|
||
|
|
self._active_markets = initial_markets
|
||
|
|
self._log.info(
|
||
|
|
"initial_discovery_complete",
|
||
|
|
count=len(initial_markets),
|
||
|
|
)
|
||
|
|
|
||
|
|
# 4. Window tracker
|
||
|
|
assets = [Asset(a) for a in self._cfg.general.assets]
|
||
|
|
timeframes = [Timeframe(t) for t in self._cfg.general.timeframes]
|
||
|
|
self._tracker = WindowTracker(assets=assets, timeframes=timeframes)
|
||
|
|
self._tracker.on_window_change(self._on_window_change)
|
||
|
|
|
||
|
|
# Link initial markets to windows
|
||
|
|
for mkt in initial_markets:
|
||
|
|
self._tracker.link_market(
|
||
|
|
asset=mkt.asset.value,
|
||
|
|
timeframe=mkt.timeframe.value,
|
||
|
|
market=mkt,
|
||
|
|
)
|
||
|
|
|
||
|
|
# 4b. Chainlink oracle monitor
|
||
|
|
self._oracle = OracleMonitor()
|
||
|
|
oracle_ok = await self._oracle.initialize()
|
||
|
|
self._log.info("oracle_init", success=oracle_ok)
|
||
|
|
|
||
|
|
# 5. Binance feed
|
||
|
|
ws_url = self._cfg.binance.ws_url
|
||
|
|
streams = "/".join(
|
||
|
|
f"{s}@trade" for s in self._cfg.binance.symbols
|
||
|
|
)
|
||
|
|
full_url = f"{ws_url}?streams={streams}"
|
||
|
|
self._binance_feed = BinanceFeed(url=full_url)
|
||
|
|
self._binance_feed.subscribe(self._on_binance_price)
|
||
|
|
|
||
|
|
# 6. Polymarket feed
|
||
|
|
self._poly_feed = PolymarketFeed(url=self._cfg.polymarket.ws_url)
|
||
|
|
self._poly_feed.on_orderbook_update(self._on_orderbook_update)
|
||
|
|
|
||
|
|
for mkt in initial_markets:
|
||
|
|
self._poly_feed.subscribe_market(mkt.up_token_id)
|
||
|
|
self._poly_feed.subscribe_market(mkt.down_token_id)
|
||
|
|
|
||
|
|
self._log.info("paper_bot_starting_feeds")
|
||
|
|
|
||
|
|
# 7. Run all tasks concurrently
|
||
|
|
tasks = [
|
||
|
|
self._binance_feed.start(),
|
||
|
|
self._poly_feed.start(),
|
||
|
|
self._discovery.discover_loop(interval_sec=_DISCOVERY_INTERVAL_S),
|
||
|
|
self._strategy_eval_loop(),
|
||
|
|
self._status_loop(),
|
||
|
|
self._summary_loop(),
|
||
|
|
]
|
||
|
|
if self._oracle and self._oracle._initialized:
|
||
|
|
tasks.append(self._oracle.poll_loop(interval=5.0))
|
||
|
|
tasks.append(self._oracle_snapshot_loop())
|
||
|
|
try:
|
||
|
|
await asyncio.gather(*tasks)
|
||
|
|
except asyncio.CancelledError:
|
||
|
|
self._log.info("gather_cancelled")
|
||
|
|
|
||
|
|
async def shutdown(self) -> None:
|
||
|
|
"""Gracefully stop all components."""
|
||
|
|
self._log.info("paper_bot_shutting_down")
|
||
|
|
self._shutdown_event.set()
|
||
|
|
|
||
|
|
if self._binance_feed is not None:
|
||
|
|
await self._binance_feed.stop()
|
||
|
|
|
||
|
|
if self._poly_feed is not None:
|
||
|
|
await self._poly_feed.stop()
|
||
|
|
|
||
|
|
# Print final summary
|
||
|
|
if self._engine is not None:
|
||
|
|
self._engine.print_summary()
|
||
|
|
|
||
|
|
self._log.info("paper_bot_stopped")
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Config override helper
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def _force_paper_config(base: Config) -> Config:
|
||
|
|
"""Return a new Config with ``general.mode`` forced to ``'paper'``."""
|
||
|
|
return Config(
|
||
|
|
general=GeneralConfig(
|
||
|
|
mode="paper",
|
||
|
|
assets=base.general.assets,
|
||
|
|
timeframes=base.general.timeframes,
|
||
|
|
log_level=base.general.log_level,
|
||
|
|
starting_balance=base.general.starting_balance,
|
||
|
|
),
|
||
|
|
temporal_arb=base.temporal_arb,
|
||
|
|
sum_to_one=base.sum_to_one,
|
||
|
|
spread_capture=base.spread_capture,
|
||
|
|
risk=base.risk,
|
||
|
|
fees=base.fees,
|
||
|
|
binance=base.binance,
|
||
|
|
polymarket=base.polymarket,
|
||
|
|
notifications=base.notifications,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Signal handling and entry point
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def _install_signal_handlers(
|
||
|
|
loop: asyncio.AbstractEventLoop, bot: PaperTradingBot
|
||
|
|
) -> None:
|
||
|
|
"""Register SIGINT / SIGTERM handlers that trigger graceful shutdown."""
|
||
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||
|
|
try:
|
||
|
|
loop.add_signal_handler(
|
||
|
|
sig,
|
||
|
|
lambda: asyncio.ensure_future(bot.shutdown()),
|
||
|
|
)
|
||
|
|
except NotImplementedError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
async def async_main() -> None:
|
||
|
|
"""Async entry point for the paper trading wrapper."""
|
||
|
|
base_config = load_config()
|
||
|
|
config = _force_paper_config(base_config)
|
||
|
|
setup_logging(config.general.log_level)
|
||
|
|
|
||
|
|
print(_BANNER)
|
||
|
|
print(f" Mode: {config.general.mode} (forced)")
|
||
|
|
print(f" Assets: {', '.join(config.general.assets)}")
|
||
|
|
print(f" Timeframes: {', '.join(config.general.timeframes)}")
|
||
|
|
print(f" Log level: {config.general.log_level}")
|
||
|
|
print(f" Balance: ${config.general.starting_balance:,.2f}")
|
||
|
|
print(f" DB: paper_trades.db")
|
||
|
|
print()
|
||
|
|
|
||
|
|
bot = PaperTradingBot(config)
|
||
|
|
|
||
|
|
loop = asyncio.get_running_loop()
|
||
|
|
_install_signal_handlers(loop, bot)
|
||
|
|
|
||
|
|
try:
|
||
|
|
await bot.start()
|
||
|
|
except KeyboardInterrupt:
|
||
|
|
pass
|
||
|
|
finally:
|
||
|
|
await bot.shutdown()
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> None:
|
||
|
|
"""Synchronous entry point for ``python paper_trade.py``."""
|
||
|
|
try:
|
||
|
|
asyncio.run(async_main())
|
||
|
|
except KeyboardInterrupt:
|
||
|
|
print("\nPaper trading shutdown complete.")
|
||
|
|
sys.exit(0)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|