#!/usr/bin/env python3
"""
BTC 5/15-min Live Trading Bot with Real-time Dashboard
Single file that combines:
- Visual terminal dashboard (rich)
- Real order execution
- Hedge management
- Auto-redemption
- Telegram notifications
Usage:
python main.py
"""
import asyncio
import json
import time
import csv
import math
import statistics
import logging
import signal
import sys
from datetime import datetime, timezone
from collections import deque
from dataclasses import dataclass, field
from typing import Optional, Dict, List, Any
from pathlib import Path
import aiohttp
import websockets
from rich.console import Console
from rich.live import Live
from rich.panel import Panel
from rich.layout import Layout
import os
from dotenv import load_dotenv # noqa: E402
# Setup logging
Path("logs").mkdir(exist_ok=True)
# Pre-load .env EARLY so proxy detection can read HTTP_PROXY from it
_PROJECT_ROOT = Path(__file__).parent
load_dotenv(_PROJECT_ROOT / ".env")
# Shared proxy helpers (HTTP CONNECT tunnel for websockets 13.x)
from src.proxy_util import ( # noqa: E402
apply_proxy_env,
get_proxy_url,
ws_connect,
ws_connect_kwargs as _ws_connect_kwargs,
)
_PROXY_URL = apply_proxy_env()
if _PROXY_URL:
print(f"[proxy] Detected proxy: {_PROXY_URL}")
print(f"[proxy] WebSocket via HTTP CONNECT tunnel; aiohttp via trust_env/proxy=")
print(f"[proxy] websockets={getattr(websockets, '__version__', '?')}")
else:
print("[proxy] No HTTP_PROXY / HTTPS_PROXY env var detected. "
"If you are in a restricted region, set them in .env.")
def _aiohttp_session(**kwargs) -> aiohttp.ClientSession:
"""Create an aiohttp ClientSession that respects env proxy settings."""
timeout = kwargs.pop("timeout", None) or aiohttp.ClientTimeout(total=30)
return aiohttp.ClientSession(
timeout=timeout,
trust_env=True,
**kwargs
)
# Main logger
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(name)s] %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
handlers=[logging.FileHandler('logs/bot.log')]
)
logger = logging.getLogger("btc_live")
# Detailed order execution logger
order_logger = logging.getLogger("btc_live.orders")
order_handler = logging.FileHandler('logs/orders.log')
order_handler.setFormatter(logging.Formatter(
'%(asctime)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
))
order_logger.addHandler(order_handler)
order_logger.setLevel(logging.DEBUG)
# Detailed hedge logger
hedge_logger = logging.getLogger("btc_live.hedges")
hedge_handler = logging.FileHandler('logs/hedges.log')
hedge_handler.setFormatter(logging.Formatter(
'%(asctime)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
))
hedge_logger.addHandler(hedge_handler)
hedge_logger.setLevel(logging.DEBUG)
# Signals logger
signal_logger = logging.getLogger("btc_live.signals")
signal_handler = logging.FileHandler('logs/signals.log')
signal_handler.setFormatter(logging.Formatter(
'%(asctime)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
))
signal_logger.addHandler(signal_handler)
signal_logger.setLevel(logging.DEBUG)
# Project imports
from src.config_loader import load_config, validate_config
from src.web_dashboard import WebSnapshotHolder, start_web_dashboard
from src.order_executor import OrderExecutor, ExecutionConfig
from src.hedge_manager import HedgeManager, HedgeConfig as HedgeManagerConfig, HedgeResult
from src.auto_redeemer import AsyncAutoRedeemer
from src.telegram_notifier import TelegramNotifier
from src.user_websocket import UserWebSocket
from src.simulation_history import SimulationHistoryLogger
# Constants
GAMMA_API = "https://gamma-api.polymarket.com"
WSS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
RTDS_URL = "wss://ws-live-data.polymarket.com"
console = Console()
# =============================================================================
# DATA CLASSES
# =============================================================================
@dataclass
class Trade:
"""Single trade record"""
timestamp: float
price: float
size: float
side: str
@dataclass
class TokenData:
"""Data for a single token (Up or Down)"""
token_id: str
name: str
best_bid: float = 0.0
best_bid_size: float = 0.0
best_ask: float = 0.0
best_ask_size: float = 0.0
trades: deque = field(default_factory=lambda: deque(maxlen=5000))
last_price: float = 0.0
last_trade_time: float = 0.0
trade_count: int = 0
volume_total: float = 0.0
volume_buy: float = 0.0
volume_sell: float = 0.0
def reset(self):
self.best_bid = 0.0
self.best_bid_size = 0.0
self.best_ask = 0.0
self.best_ask_size = 0.0
self.trades.clear()
self.last_price = 0.0
self.last_trade_time = 0.0
self.trade_count = 0
self.volume_total = 0.0
self.volume_buy = 0.0
self.volume_sell = 0.0
@dataclass
class MarketState:
"""Current market state"""
market_id: str = ""
condition_id: str = ""
slug: str = ""
end_time: float = 0.0
up_token: Optional[TokenData] = None
down_token: Optional[TokenData] = None
connected: bool = False
last_update: float = 0.0
# Chainlink BTC/USD price tracking
btc_anchor_price: float = 0.0 # Price at market start
btc_current_price: float = 0.0 # Latest Chainlink price
btc_last_update: float = 0.0 # Timestamp of last price update
btc_connected: bool = False # RTDS connection status
@dataclass
class Position:
"""Current open position"""
token_name: str
token_id: str
opposite_token_id: str
entry_price: float
contracts: int
entry_time: float
market_slug: str
hedged: bool = False
hedge_contracts: int = 0
hedge_price: float = 0.0
min_price_seen: float = 0.0 # Lowest price after entry (for drawdown tracking)
@dataclass
class TradeRecord:
"""Completed trade record"""
market_slug: str
token_name: str
entry_price: float
exit_price: float
contracts: int
pnl: float
won: bool
timestamp: float
max_drawdown_abs: float = 0.0 # Max absolute price drop from entry
max_drawdown_pct: float = 0.0 # Max percentage drawdown from entry
# =============================================================================
# UTILITIES
# =============================================================================
class IndicatorCalculator:
@staticmethod
def get_trades_in_window(trades: deque, window_seconds: float) -> List[Trade]:
now = time.time()
cutoff = now - window_seconds
return [t for t in trades if t.timestamp >= cutoff]
@staticmethod
def calc_vwap(trades: List[Trade]) -> float:
if not trades:
return 0.0
total_value = sum(t.price * t.size for t in trades)
total_volume = sum(t.size for t in trades)
return total_value / total_volume if total_volume > 0 else 0.0
@staticmethod
def calc_deviation(current_price: float, vwap: float) -> float:
if vwap == 0:
return 0.0
return ((current_price - vwap) / vwap) * 100
@staticmethod
def calc_momentum(trades: deque, current_price: float, window: float = 120, avg_band: float = 1.5) -> Optional[float]:
"""
Price change vs average price ~window seconds ago.
Takes all trades in [now-window-avg_band, now-window+avg_band] (3s band),
computes arithmetic mean, returns % change from that to current_price.
Returns None if no trades found in the band (not enough history).
"""
now = time.time()
band_start = now - window - avg_band
band_end = now - window + avg_band
band_prices = [t.price for t in trades if band_start <= t.timestamp <= band_end]
if not band_prices:
return None
avg_price_ago = sum(band_prices) / len(band_prices)
if avg_price_ago == 0:
return None
return ((current_price - avg_price_ago) / avg_price_ago) * 100
@staticmethod
def calc_zscore(trades: deque, current_price: float, window: float = 5) -> float:
now = time.time()
recent = [t for t in trades if t.timestamp >= now - window]
if len(recent) < 2:
return 0.0
prices = [t.price for t in recent]
mean_price = statistics.mean(prices)
std_price = statistics.stdev(prices) if len(prices) > 1 else 0.001
return (current_price - mean_price) / std_price if std_price > 0 else 0.0
class WinRateTable:
def __init__(self, csv_path: str):
self.data = {}
self.price_ranges = []
self._load(csv_path)
def _load(self, csv_path):
try:
with open(csv_path, 'r') as f:
reader = csv.reader(f)
next(reader) # Skip header
for row in reader:
if not row or not row[0]:
continue
price_range = row[0]
self.price_ranges.append(price_range)
self.data[price_range] = {}
for i, val in enumerate(row[1:], start=0):
if val:
try:
self.data[price_range][i] = float(val)
except ValueError:
pass
except Exception as e:
logger.warning(f"Could not load win_rate.csv: {e}")
def get_winrate(self, price: float, minute: int, interval_minutes: int = 15) -> Optional[float]:
price_range = None
for pr in self.price_ranges:
try:
low, high = pr.split('-')
if float(low) <= price <= float(high):
price_range = pr
break
except:
continue
if not price_range and price > 0.99 and self.price_ranges:
price_range = self.price_ranges[-1]
if not price_range:
return None
cap = max(0, interval_minutes - 1)
minute = max(0, min(cap, minute))
return self.data.get(price_range, {}).get(minute)
# =============================================================================
# TRADING STATS
# =============================================================================
class TradingStats:
def __init__(self, log_file: str = "logs/trading_log.json"):
self.log_file = Path(log_file)
self.position: Optional[Position] = None
self.trades: List[TradeRecord] = []
self.markets_seen: int = 0
self.current_market_slug: str = ""
self.position_closed_this_market: bool = False
self.entry_blocked: bool = False # Блокировка повторных попыток после таймаута
self._load()
def _load(self):
try:
if self.log_file.exists():
with open(self.log_file, 'r') as f:
data = json.load(f)
self.trades = [TradeRecord(**t) for t in data.get('trades', [])]
self.markets_seen = data.get('markets_seen', 0)
except Exception:
pass
def summary_dict(self) -> Dict[str, Any]:
"""Aggregates for dashboards and simulation summary files."""
tc = len(self.trades)
wins = sum(1 for t in self.trades if t.won)
losses = tc - wins
total = sum(t.pnl for t in self.trades)
pnls = [t.pnl for t in self.trades]
wr = (wins / tc * 100.0) if tc else 0.0
return {
"total_pnl_usd": round(total, 6),
"trade_count": tc,
"wins": wins,
"losses": losses,
"win_rate_pct": round(wr, 4),
"avg_trade_pnl_usd": round(total / tc, 6) if tc else 0.0,
"best_trade_pnl_usd": round(max(pnls), 6) if pnls else None,
"worst_trade_pnl_usd": round(min(pnls), 6) if pnls else None,
"last_close_unix": max((t.timestamp for t in self.trades), default=None),
}
def _save(self):
try:
self.log_file.parent.mkdir(parents=True, exist_ok=True)
data = {
'trades': [t.__dict__ for t in self.trades],
'markets_seen': self.markets_seen,
'summary': self.summary_dict(),
}
with open(self.log_file, 'w') as f:
json.dump(data, f, indent=2)
except Exception:
pass
def new_market(self, slug: str):
if slug != self.current_market_slug:
self.current_market_slug = slug
self.markets_seen += 1
self.position = None
self.position_closed_this_market = False
self.entry_blocked = False # Сброс блокировки для нового рынка
self._save()
def can_enter(self) -> bool:
return self.position is None and not self.position_closed_this_market and not self.entry_blocked
def block_entry(self, reason: str = ""):
"""Блокирует повторные попытки входа на текущем рынке."""
self.entry_blocked = True
if reason:
logger.warning(f"Entry blocked: {reason}")
def record_entry(self, token_name: str, token_id: str, opposite_token_id: str,
price: float, contracts: int, market_slug: str):
self.position = Position(
token_name=token_name,
token_id=token_id,
opposite_token_id=opposite_token_id,
entry_price=price,
contracts=contracts,
entry_time=time.time(),
market_slug=market_slug,
min_price_seen=price # Start tracking from entry price
)
def record_hedge(self, contracts: int, price: float):
if self.position:
self.position.hedged = True
self.position.hedge_contracts = contracts
self.position.hedge_price = price
def update_drawdown(self, current_price: float):
"""Track minimum price seen since entry for drawdown calculation."""
if self.position and current_price > 0:
if current_price < self.position.min_price_seen:
self.position.min_price_seen = current_price
def close_position(self, final_price: float) -> Optional[TradeRecord]:
if not self.position:
return None
won = final_price >= 0.70 # Win threshold
entry_cost = self.position.contracts * self.position.entry_price
if won:
pnl = self.position.contracts - entry_cost
else:
pnl = -entry_cost
# Calculate max drawdown from entry
dd_abs = max(0, self.position.entry_price - self.position.min_price_seen)
dd_pct = (dd_abs / self.position.entry_price * 100) if self.position.entry_price > 0 else 0
record = TradeRecord(
market_slug=self.position.market_slug,
token_name=self.position.token_name,
entry_price=self.position.entry_price,
exit_price=final_price,
contracts=self.position.contracts,
pnl=pnl,
won=won,
timestamp=time.time(),
max_drawdown_abs=dd_abs,
max_drawdown_pct=dd_pct,
)
self.trades.append(record)
self.position = None
self.position_closed_this_market = True
self._save()
return record
@property
def total_pnl(self) -> float:
return sum(t.pnl for t in self.trades)
@property
def win_count(self) -> int:
return sum(1 for t in self.trades if t.won)
@property
def trade_count(self) -> int:
return len(self.trades)
@property
def win_rate(self) -> float:
if not self.trades:
return 0.0
return (self.win_count / self.trade_count) * 100
# =============================================================================
# WEBSOCKET CLIENT
# =============================================================================
class WebSocketClient:
def __init__(self, state: MarketState):
self.state = state
self.running = False
self._tokens_validated = False
self._ws: Optional[websockets.WebSocketClientProtocol] = None
def _validate_tokens(self):
"""Log token prices after first WebSocket data received.
NOTE: Token swap logic was REMOVED because it was buggy.
The API token assignment should be trusted.
"""
if self._tokens_validated:
return
up = self.state.up_token
down = self.state.down_token
if not up or not down:
return
up_price = up.best_bid or up.best_ask or up.last_price
down_price = down.best_bid or down.best_ask or down.last_price
# Only log once we have valid prices
if up_price > 0.05 and down_price > 0.05:
price_sum = up_price + down_price
logger.info(f"Tokens validated: UP={up_price:.2f}, DOWN={down_price:.2f}, sum={price_sum:.2f}")
self._tokens_validated = True
async def connect(self):
self.running = True
while self.running:
try:
async with ws_connect(WSS_URL, **_ws_connect_kwargs()) as ws:
self._ws = ws
self.state.connected = True
token_ids = []
if self.state.up_token:
token_ids.append(self.state.up_token.token_id)
if self.state.down_token:
token_ids.append(self.state.down_token.token_id)
# Log exact token_ids being subscribed
logger.info(f"WebSocket subscribing to tokens:")
logger.info(f" UP: {self.state.up_token.token_id[:40]}..." if self.state.up_token else " UP: None")
logger.info(f" DOWN: {self.state.down_token.token_id[:40]}..." if self.state.down_token else " DOWN: None")
await ws.send(json.dumps({"assets_ids": token_ids, "type": "market"}))
async for message in ws:
if not self.running:
break
await self._handle_message(message)
self._ws = None
except websockets.ConnectionClosed:
self._ws = None
self.state.connected = False
if self.running:
await asyncio.sleep(1)
except Exception:
self._ws = None
self.state.connected = False
if self.running:
await asyncio.sleep(2)
async def disconnect(self):
"""Gracefully close WebSocket connection with code 1000 (normal closure)."""
self.running = False
if self._ws:
try:
await self._ws.close(code=1000, reason="Normal shutdown")
logger.info("WebSocket closed gracefully (code 1000)")
except Exception as e:
logger.warning(f"Error during WebSocket close: {e}")
finally:
self._ws = None
self.state.connected = False
async def _handle_message(self, message: str):
try:
data = json.loads(message)
if isinstance(data, list):
for item in data:
if isinstance(item, dict):
await self._process_item(item)
elif isinstance(data, dict):
await self._process_item(data)
self.state.last_update = time.time()
# Validate tokens after receiving price data
if not self._tokens_validated:
self._validate_tokens()
except Exception:
pass
async def _process_item(self, data: dict):
event_type = data.get("event_type", "")
if event_type == "last_trade_price":
asset_id = data.get("asset_id")
token = self._get_token(asset_id)
if not token and asset_id:
# Asset ID doesn't match our tokens - might indicate subscription issue
logger.warning(f"Received price for unknown asset: {asset_id[:30]}...")
logger.warning(f" Our UP token: {self.state.up_token.token_id[:30] if self.state.up_token else 'None'}...")
logger.warning(f" Our DOWN token: {self.state.down_token.token_id[:30] if self.state.down_token else 'None'}...")
if token:
price = float(data.get("price", 0))
size = float(data.get("size", 0))
side = data.get("side", "BUY")
if price > 0 and size > 0:
token.last_price = price
token.last_trade_time = time.time()
token.trades.append(Trade(time.time(), price, size, side))
token.trade_count += 1
token.volume_total += size
if side == "BUY":
token.volume_buy += size
else:
token.volume_sell += size
elif event_type == "price_change":
for change in data.get("price_changes", []):
token = self._get_token(change.get("asset_id"))
if token:
if change.get("best_bid"):
token.best_bid = float(change["best_bid"])
if change.get("best_ask"):
token.best_ask = float(change["best_ask"])
elif event_type == "book":
token = self._get_token(data.get("asset_id"))
if token:
bids = data.get("bids", [])
if bids:
bids.sort(key=lambda x: float(x["price"]), reverse=True)
token.best_bid = float(bids[0]["price"])
token.best_bid_size = float(bids[0]["size"])
asks = data.get("asks", [])
if asks:
asks.sort(key=lambda x: float(x["price"]))
token.best_ask = float(asks[0]["price"])
token.best_ask_size = float(asks[0]["size"])
def _get_token(self, asset_id: str) -> Optional[TokenData]:
if self.state.up_token and asset_id == self.state.up_token.token_id:
return self.state.up_token
elif self.state.down_token and asset_id == self.state.down_token.token_id:
return self.state.down_token
return None
def stop(self):
"""Stop WebSocket (sync version - just sets flag)."""
self.running = False
async def stop_graceful(self):
"""Stop WebSocket gracefully with proper close."""
await self.disconnect()
# =============================================================================
# CHAINLINK BTC PRICE CLIENT
# =============================================================================
class ChainlinkPriceClient:
"""
Always-on BTC/USD price stream from Polymarket RTDS (Chainlink source).
Connects to wss://ws-live-data.polymarket.com and subscribes to
crypto_prices_chainlink for btc/usd.
Autonomously tracks market boundaries (epoch-aligned to interval length)
and snapshots the anchor price at the exact boundary crossing, independent
of the bot's market finding flow. This ensures the anchor is captured
within ~1 second of the real boundary, not 5-15s later.
"""
def __init__(self, state: 'MarketState', market_duration_sec: int):
self.state = state
self._market_duration = int(market_duration_sec)
if self._market_duration <= 0:
self._market_duration = 900
self.running = False
self._ws = None
self._ping_task: Optional[asyncio.Task] = None
# Track which window the current anchor belongs to
self._current_window: int = 0
# Buffer: last price before boundary (for most accurate anchor)
self._last_price_before_boundary: float = 0.0
self._last_price_ts: float = 0.0
def _get_window(self, ts: float) -> int:
"""Window start timestamp (epoch) for the configured interval."""
d = self._market_duration
return int(ts) // d * d
DATA_TIMEOUT = 30 # seconds without any message → force reconnect
async def connect(self):
"""Connect to RTDS and subscribe to Chainlink BTC/USD prices. Always on."""
self.running = True
self._last_msg_time = time.time()
while self.running:
try:
async with ws_connect(RTDS_URL, **_ws_connect_kwargs()) as ws:
self._ws = ws
self.state.btc_connected = True
self._last_msg_time = time.time()
logger.info("RTDS Chainlink connected")
# Subscribe to chainlink prices (all symbols, filter in code)
subscribe_msg = json.dumps({
"action": "subscribe",
"subscriptions": [{
"topic": "crypto_prices_chainlink",
"type": "*",
"filters": ""
}]
})
await ws.send(subscribe_msg)
# Start ping task and watchdog
self._ping_task = asyncio.create_task(self._ping_loop(ws))
watchdog_task = asyncio.create_task(self._watchdog(ws))
try:
async for message in ws:
if not self.running:
break
self._last_msg_time = time.time()
self._handle_message(message)
finally:
watchdog_task.cancel()
try:
await watchdog_task
except asyncio.CancelledError:
pass
self._ws = None
except websockets.ConnectionClosed:
self._ws = None
self.state.btc_connected = False
if self.running:
logger.warning("RTDS Chainlink disconnected, reconnecting in 2s...")
await asyncio.sleep(2)
except Exception as e:
self._ws = None
self.state.btc_connected = False
if self.running:
logger.warning(
f"RTDS Chainlink error: {type(e).__name__}: {e or repr(e)}, "
f"reconnecting in 5s..."
)
await asyncio.sleep(5)
finally:
if self._ping_task and not self._ping_task.done():
self._ping_task.cancel()
try:
await self._ping_task
except:
pass
self._ping_task = None
async def _watchdog(self, ws):
"""Force-close WebSocket if no messages received for DATA_TIMEOUT seconds."""
try:
while self.running:
await asyncio.sleep(5)
silence = time.time() - self._last_msg_time
if silence > self.DATA_TIMEOUT:
logger.warning(
f"RTDS Chainlink watchdog: no data for {silence:.0f}s, forcing reconnect"
)
self.state.btc_connected = False
await ws.close()
break
except asyncio.CancelledError:
pass
def _handle_message(self, message: str):
"""Parse incoming Chainlink price message and auto-detect market boundaries."""
try:
if not isinstance(message, str) or not message.strip():
return
data = json.loads(message)
topic = data.get("topic", "")
if topic != "crypto_prices_chainlink":
return
payload = data.get("payload", {})
symbol = payload.get("symbol", "")
if symbol != "btc/usd":
return
price = float(payload.get("value", 0))
if price <= 0:
return
# Use Chainlink's own timestamp (ms) for precise boundary detection
chainlink_ts_ms = payload.get("timestamp", 0)
if chainlink_ts_ms:
price_ts = chainlink_ts_ms / 1000.0
else:
price_ts = time.time()
now = time.time()
# Update current price (always)
self.state.btc_current_price = price
self.state.btc_last_update = now
# === CALIBRATION LOG: every tick within [-15s..+5s] of any boundary ===
price_window = self._get_window(price_ts)
next_boundary = price_window + self._market_duration
secs_to_next = next_boundary - price_ts
secs_from_prev = price_ts - price_window
# Log if within 15s before next boundary OR 5s after current boundary start
if secs_to_next <= 15.0 or secs_from_prev <= 5.0:
cl_time = datetime.fromtimestamp(price_ts, tz=timezone.utc).strftime('%H:%M:%S.%f')[:-3]
local_time = datetime.fromtimestamp(now, tz=timezone.utc).strftime('%H:%M:%S.%f')[:-3]
if secs_from_prev <= 5.0:
offset_str = f"+{secs_from_prev:.3f}s after {datetime.fromtimestamp(price_window, tz=timezone.utc).strftime('%H:%M:%S')}"
else:
offset_str = f"-{secs_to_next:.3f}s before {datetime.fromtimestamp(next_boundary, tz=timezone.utc).strftime('%H:%M:%S')}"
logger.info(
f"BTC_TICK {cl_time} (local {local_time}) ${price:,.2f} [{offset_str}]"
)
# Detect window boundary crossing
if self._current_window == 0:
# First price ever — initialize
self._current_window = price_window
self.state.btc_anchor_price = price
logger.info(
f"BTC Chainlink init: ${price:,.2f} "
f"(window {self._current_window}, "
f"ts={datetime.fromtimestamp(price_ts, tz=timezone.utc).strftime('%H:%M:%S.%f')[:-3]})"
)
elif price_window != self._current_window:
# === NEW WINDOW === use FIRST tick of new window as anchor
# Calibrated: reference program uses the first tick AT or AFTER boundary
old_anchor = self.state.btc_anchor_price
old_window = self._current_window
self.state.btc_anchor_price = price # First tick of new window
self._current_window = price_window
boundary_time = datetime.fromtimestamp(price_window, tz=timezone.utc).strftime('%H:%M:%S')
price_time = datetime.fromtimestamp(price_ts, tz=timezone.utc).strftime('%H:%M:%S.%f')[:-3]
delay_ms = (price_ts - price_window) * 1000
logger.info(
f"BTC anchor reset: ${self.state.btc_anchor_price:,.2f} "
f"(boundary {boundary_time}, first tick at {price_time}, "
f"delay {delay_ms:.0f}ms, prev anchor ${old_anchor:,.2f})"
)
# Always buffer the latest price for next boundary crossing
self._last_price_before_boundary = price
self._last_price_ts = price_ts
except (json.JSONDecodeError, ValueError, KeyError):
pass
async def _ping_loop(self, ws):
"""Send ping every 5 seconds to keep connection alive."""
try:
while self.running:
await asyncio.sleep(5)
try:
await ws.ping()
except Exception:
break
except asyncio.CancelledError:
pass
async def disconnect(self):
"""Gracefully close RTDS WebSocket connection."""
self.running = False
if self._ping_task and not self._ping_task.done():
self._ping_task.cancel()
try:
await self._ping_task
except:
pass
self._ping_task = None
if self._ws:
try:
# Unsubscribe before closing
unsub_msg = json.dumps({
"action": "unsubscribe",
"subscriptions": [{
"topic": "crypto_prices_chainlink",
"type": "*",
"filters": ""
}]
})
await self._ws.send(unsub_msg)
await self._ws.close(code=1000, reason="Normal shutdown")
logger.info("RTDS Chainlink closed gracefully")
except Exception as e:
logger.warning(f"RTDS close error: {e}")
finally:
self._ws = None
self.state.btc_connected = False
# =============================================================================
# DASHBOARD
# =============================================================================
class Dashboard:
def __init__(self, state: MarketState, stats: TradingStats, config: Any):
self.state = state
self.stats = stats
self.config = config
self.calc = IndicatorCalculator()
win_rate_path = Path(__file__).parent / config.strategy.win_rate_csv
self.winrate_table = WinRateTable(str(win_rate_path))
self.last_signal = ""
self.entry_flash = False
self.hedge_flash = False
def _fmt_price(self, price: float) -> str:
if price >= 0.6:
return f"[green]{price:.3f}[/green]"
elif price <= 0.4:
return f"[red]{price:.3f}[/red]"
return f"[yellow]{price:.3f}[/yellow]"
def _fmt_dev(self, dev: float) -> str:
if dev > 5:
return f"[bold green]+{dev:.1f}%[/bold green]"
elif dev > 0:
return f"[green]+{dev:.1f}%[/green]"
elif dev < -5:
return f"[bold red]{dev:.1f}%[/bold red]"
elif dev < 0:
return f"[red]{dev:.1f}%[/red]"
return f"{dev:+.1f}%"
def _fmt_zscore(self, z: float) -> str:
if z > 2:
return f"[bold magenta]+{z:.2f}[/bold magenta] ⚡"
elif z > 1:
return f"[magenta]+{z:.2f}[/magenta]"
elif z < -2:
return f"[bold cyan]{z:.2f}[/bold cyan] ⚡"
elif z < -1:
return f"[cyan]{z:.2f}[/cyan]"
return f"{z:+.2f}"
def create_header(self) -> Panel:
now = time.time()
time_left = max(0, self.state.end_time - now)
minutes = int(time_left // 60)
seconds = int(time_left % 60)
if time_left < 60:
timer = f"[bold red]⏱️ {seconds}s[/bold red]"
elif time_left < 180:
timer = f"[yellow]⏱️ {minutes}:{seconds:02d}[/yellow]"
else:
timer = f"[green]⏱️ {minutes}:{seconds:02d}[/green]"
status = "[green]● LIVE[/green]" if self.state.connected else "[red]○ DISCONNECTED[/red]"
if getattr(self.config, "simulation", None) and self.config.simulation.enabled:
mode = "[bold yellow]SIMULATION (no real orders)[/bold yellow]"
else:
mode = "[bold cyan]REAL TRADING[/bold cyan]"
header = f"{timer} | {self.state.slug} | {status} | {mode}"
im = self.config.market.interval_minutes
return Panel(header, title=f"[bold]BTC {im}-Min Live Bot[/bold]")
def create_token_panel(self, token: TokenData, label: str) -> Panel:
if not token:
return Panel("No data", title=label)
lines = []
if token.best_ask > 0:
lines.append(f"[red]ASK {token.best_ask:.3f}[/red] | {token.best_ask_size:.0f}")
else:
lines.append(f"[red]ASK ---[/red]")
lines.append("─" * 20)
lines.append(f"[bold white]LAST {token.last_price:.3f}[/bold white]")
if token.best_ask > 0 and token.best_bid > 0:
spread = token.best_ask - token.best_bid
lines.append(f"[dim]Spread: {spread:.3f}[/dim]")
lines.append("─" * 20)
if token.best_bid > 0:
lines.append(f"[green]BID {token.best_bid:.3f}[/green] | {token.best_bid_size:.0f}")
else:
lines.append(f"[green]BID ---[/green]")
return Panel(
"\n".join(lines),
title=f"[bold]{label}[/bold] - {self._fmt_price(token.last_price)}",
border_style="green" if "Up" in label else "red"
)
def _fmt_momentum(self, m: Optional[float]) -> str:
if m is None:
return "[dim]N/A[/dim]"
if m > 0:
return f"[green]+{m:.2f}%[/green]"
elif m < 0:
return f"[red]{m:.2f}%[/red]"
return f"[cyan]0.00%[/cyan]"
def create_indicators_panel(self, token: TokenData, label: str) -> Panel:
if not token or not token.trades:
return Panel("Waiting for data...", title=f"{label} Indicators")
mom_window = self.config.strategy.momentum_window_sec
vwap_window = self.config.strategy.vwap_window_sec
vwap = self.calc.calc_vwap(self.calc.get_trades_in_window(token.trades, vwap_window))
deviation = self.calc.calc_deviation(token.last_price, vwap)
zscore = self.calc.calc_zscore(token.trades, token.last_price, window=5)
momentum = self.calc.calc_momentum(token.trades, token.last_price, window=mom_window)
def fmt_vol(v):
if v >= 1_000_000:
return f"{v/1_000_000:.1f}M"
elif v >= 1_000:
return f"{v/1_000:.1f}K"
return f"{v:.0f}"
lines = [
f"VWAP {vwap_window}s: {vwap:.4f}",
f"Deviation: {self._fmt_dev(deviation)}",
f"Z-Score 5s: {self._fmt_zscore(zscore)}",
f"Mom {mom_window}s: {self._fmt_momentum(momentum)}",
"",
f"Trades: {token.trade_count}",
f"Volume: {fmt_vol(token.volume_total)}",
f" Buy: [green]{fmt_vol(token.volume_buy)}[/green]",
f" Sell: [red]{fmt_vol(token.volume_sell)}[/red]",
]
return Panel("\n".join(lines), title=f"{label} Indicators", border_style="blue")
def create_strategy_panel(self) -> Panel:
if not self.state.up_token or not self.state.down_token:
return Panel("Waiting for data...", title="Strategy Signal")
up = self.state.up_token
down = self.state.down_token
vwap_window = self.config.strategy.vwap_window_sec
up_vwap = self.calc.calc_vwap(self.calc.get_trades_in_window(up.trades, vwap_window))
down_vwap = self.calc.calc_vwap(self.calc.get_trades_in_window(down.trades, vwap_window))
up_dev = self.calc.calc_deviation(up.last_price, up_vwap)
down_dev = self.calc.calc_deviation(down.last_price, down_vwap)
mom_window = self.config.strategy.momentum_window_sec
up_mom = self.calc.calc_momentum(up.trades, up.last_price, window=mom_window)
down_mom = self.calc.calc_momentum(down.trades, down.last_price, window=mom_window)
time_left = max(0, self.state.end_time - time.time())
time_minutes = time_left / 60
span = self.config.market.interval_minutes
time_bin = int((span - 1) - time_minutes)
time_bin = max(0, min(time_bin, span - 1))
if up.last_price > down.last_price:
fav_name = "UP"
fav_price = up.last_price
fav_dev = up_dev
fav_mom = up_mom
else:
fav_name = "DOWN"
fav_price = down.last_price
fav_dev = down_dev
fav_mom = down_mom
base_wr = self.winrate_table.get_winrate(fav_price, time_bin, span)
wr_str = f"{base_wr:.1f}%" if base_wr else "N/A"
min_price = self.config.strategy.min_price
max_price = self.config.strategy.max_price
min_elapsed = self.config.strategy.min_elapsed_sec
min_dev = self.config.strategy.min_deviation_pct
max_dev = self.config.strategy.max_deviation_pct
no_entry_cutoff = self.config.strategy.no_entry_before_end_sec
elapsed_sec = self.config.market.duration_sec - time_left
price_ok = min_price <= fav_price <= max_price
time_ok = elapsed_sec >= min_elapsed
dev_ok = fav_dev > min_dev and fav_dev < max_dev
mom_ok = fav_mom is not None and fav_mom > 5
time_cutoff_ok = time_left > no_entry_cutoff
signal = "⏳ WAIT"
signal_color = "yellow"
if not time_cutoff_ok:
signal = f"🚫 NO ENTRY (< {no_entry_cutoff}s left)"
signal_color = "red"
self.last_signal = ""
elif price_ok and time_ok and dev_ok and mom_ok:
signal = f"✅ BUY {fav_name}"
signal_color = "bold green"
self.last_signal = f"BUY_{fav_name}"
elif fav_price >= 0.70 and time_ok:
if not mom_ok:
signal = "🟡 ALMOST (need Mom>0%)"
elif fav_dev >= max_dev:
signal = f"🟡 ALMOST (Dev≥{max_dev}%)"
else:
signal = "🟡 ALMOST (need dev)"
self.last_signal = ""
else:
self.last_signal = ""
if not time_ok:
signal = f"⏳ WAIT (elapsed<{min_elapsed}s)"
elif not price_ok:
signal = f"⏳ WAIT (P not in range)"
elif not dev_ok:
if fav_dev >= max_dev:
signal = f"⏳ WAIT (Dev≥{max_dev}%)"
else:
signal = f"⏳ WAIT (Dev<{min_dev}%)"
elif not mom_ok:
signal = f"⏳ WAIT (Mom≤0%)"
lines = [
f"Favorite: [{signal_color}]{fav_name} ({fav_price:.3f})[/{signal_color}] — WR: [cyan]{wr_str}[/cyan]",
f"Signal: [{signal_color}][bold]{signal}[/bold][/{signal_color}]",
"",
f"Price: {self._fmt_price(fav_price)} (range: {min_price}-{max_price})",
f"Deviation: {self._fmt_dev(fav_dev)} (need {min_dev}%–{max_dev}%)",
f"Momentum: {self._fmt_momentum(fav_mom)}",
f"Elapsed: {int(elapsed_sec)}s (need ≥{min_elapsed}s) [bin {time_bin}]",
"",
f"Up: {self._fmt_price(up.last_price)} | Dev: {self._fmt_dev(up_dev)} | Mom: {self._fmt_momentum(up_mom)}",
f"Down: {self._fmt_price(down.last_price)} | Dev: {self._fmt_dev(down_dev)} | Mom: {self._fmt_momentum(down_mom)}",
]
title = f"[bold]Strategy: P {min_price}-{max_price}, T≥{min_elapsed}s, Dev {min_dev}%-{max_dev}%[/bold]"
border = "green" if signal_color == "bold green" else "magenta"
return Panel("\n".join(lines), title=title, border_style=border)
def create_trading_panel(self) -> Panel:
s = self.stats
bet = self.config.entry.bet_amount_usd
wr_str = f"{s.win_rate:.1f}%" if s.trade_count > 0 else "N/A"
stats_line = f"📊 Markets: {s.markets_seen} | Trades: {s.trade_count} | WR: {wr_str}"
pnl_color = "green" if s.total_pnl >= 0 else "red"
pnl_line = f"💰 PnL: [{pnl_color}]${s.total_pnl:+.2f}[/{pnl_color}]"
if s.position:
pos = s.position
if pos.token_name == "UP" and self.state.up_token:
current_price = self.state.up_token.best_bid or self.state.up_token.last_price
elif pos.token_name == "DOWN" and self.state.down_token:
current_price = self.state.down_token.best_bid or self.state.down_token.last_price
else:
current_price = pos.entry_price
unrealized = (pos.contracts * current_price) - (pos.contracts * pos.entry_price)
ur_color = "green" if unrealized >= 0 else "red"
hedge_str = " [cyan]🛡️ HEDGED[/cyan]" if pos.hedged else ""
flash = "🔔 " if self.entry_flash else ""
self.entry_flash = False
pos_line = f"{flash}🟢 LONG {pos.token_name} @ {pos.entry_price:.3f} ({pos.contracts} contracts){hedge_str}"
ur_line = f" Unrealized: [{ur_color}]${unrealized:+.2f}[/{ur_color}] (price: {current_price:.3f})"
# Live drawdown
dd_price = max(0, pos.entry_price - pos.min_price_seen)
dd_pct = (dd_price / pos.entry_price * 100) if pos.entry_price > 0 else 0
dd_usd = dd_price * pos.contracts
if dd_price > 0:
ur_line += f"\n Max DD: [red]-${dd_usd:.2f} (-{dd_pct:.1f}%)[/red] (low: {pos.min_price_seen:.3f})"
else:
pos_line = "⏳ No position (waiting for signal)"
ur_line = ""
last_trades_lines = []
for trade in s.trades[-3:][::-1]:
icon = "✅" if trade.won else "❌"
last_trades_lines.append(f" {icon} {trade.token_name} @ {trade.entry_price:.2f} → ${trade.pnl:+.2f}")
lines = [stats_line, pnl_line, "", pos_line]
if ur_line:
lines.append(ur_line)
if last_trades_lines:
lines.append("")
lines.append("Last trades:")
lines.extend(last_trades_lines)
border = "bold yellow" if self.entry_flash or self.hedge_flash else "cyan"
self.hedge_flash = False
return Panel("\n".join(lines), title=f"[bold]💰 REAL Trading (${bet:.0f}/trade)[/bold]", border_style=border)
def create_btc_price_panel(self) -> Panel:
"""Panel showing Chainlink BTC/USD price and deviation from market start."""
s = self.state
if s.btc_current_price <= 0:
status = "[green]● LIVE[/green]" if s.btc_connected else "[red]○ OFF[/red]"
return Panel(
f"Chainlink {status}\nWaiting for price...",
title="[bold]₿ BTC/USD (Chainlink)[/bold]",
border_style="dim"
)
# Connection status
status = "[green]●[/green]" if s.btc_connected else "[red]○[/red]"
# Freshness indicator
age = time.time() - s.btc_last_update if s.btc_last_update > 0 else 999
if age < 5:
fresh = "[green]LIVE[/green]"
elif age < 30:
fresh = f"[yellow]{int(age)}s ago[/yellow]"
else:
fresh = f"[red]{int(age)}s ago[/red]"
lines = [
f"Price: [bold white]${s.btc_current_price:,.2f}[/bold white] {status} {fresh}",
]
if s.btc_anchor_price > 0:
dev_abs = s.btc_current_price - s.btc_anchor_price
dev_pct = (dev_abs / s.btc_anchor_price) * 100 if s.btc_anchor_price else 0
# Color based on direction
if dev_abs > 0:
dev_abs_str = f"[green]+${dev_abs:,.2f}[/green]"
dev_pct_str = f"[green]+{dev_pct:.3f}%[/green]"
elif dev_abs < 0:
dev_abs_str = f"[red]-${abs(dev_abs):,.2f}[/red]"
dev_pct_str = f"[red]{dev_pct:.3f}%[/red]"
else:
dev_abs_str = "$0.00"
dev_pct_str = "0.000%"
lines.append(f"Anchor: [dim]${s.btc_anchor_price:,.2f}[/dim]")
lines.append(f"Deviation: {dev_abs_str} ({dev_pct_str})")
else:
lines.append("[dim]Anchor: waiting for market start...[/dim]")
return Panel(
"\n".join(lines),
title="[bold]₿ BTC/USD (Chainlink)[/bold]",
border_style="yellow"
)
def render(self) -> Layout:
layout = Layout()
layout.split_column(
Layout(self.create_header(), name="header", size=3),
Layout(name="body"),
Layout(name="footer", size=16),
Layout(self.create_btc_price_panel(), name="btc_price", size=6)
)
layout["body"].split_row(
Layout(name="left"),
Layout(name="right")
)
layout["left"].split_column(
Layout(self.create_token_panel(self.state.up_token, "⬆️ UP"), name="up_book"),
Layout(self.create_indicators_panel(self.state.up_token, "UP"), name="up_ind")
)
layout["right"].split_column(
Layout(self.create_token_panel(self.state.down_token, "⬇️ DOWN"), name="down_book"),
Layout(self.create_indicators_panel(self.state.down_token, "DOWN"), name="down_ind")
)
layout["footer"].split_row(
Layout(name="strategy"),
Layout(name="trading")
)
layout["strategy"].update(self.create_strategy_panel())
layout["trading"].update(self.create_trading_panel())
return layout
def build_web_snapshot(self) -> dict:
"""Plain dict for the HTTP dashboard (same numbers as terminal panels; no Rich markup)."""
now = time.time()
time_left = max(0.0, self.state.end_time - now)
sim = bool(getattr(self.config, "simulation", None) and self.config.simulation.enabled)
header = {
"slug": self.state.slug or "—",
"time_left_sec": time_left,
"elapsed_sec": max(0.0, self.config.market.duration_sec - time_left),
"ws_connected": bool(self.state.connected),
"simulation": sim,
"interval_minutes": self.config.market.interval_minutes,
}
def token_block(token: Optional[TokenData]) -> Optional[dict]:
if not token:
return None
book = {
"best_bid": token.best_bid,
"best_bid_size": token.best_bid_size,
"best_ask": token.best_ask,
"best_ask_size": token.best_ask_size,
"last_price": token.last_price,
"trade_count": token.trade_count,
"volume_total": token.volume_total,
"volume_buy": token.volume_buy,
"volume_sell": token.volume_sell,
}
ind = None
if token.trades:
vw = self.config.strategy.vwap_window_sec
mw = self.config.strategy.momentum_window_sec
vwap = self.calc.calc_vwap(self.calc.get_trades_in_window(token.trades, vw))
ind = {
"vwap_window_sec": vw,
"vwap": vwap,
"deviation_pct": self.calc.calc_deviation(token.last_price, vwap),
"zscore": self.calc.calc_zscore(token.trades, token.last_price, window=5),
"momentum_window_sec": mw,
"momentum_pct": self.calc.calc_momentum(token.trades, token.last_price, window=mw),
}
return {"book": book, "indicators": ind}
strategy: dict = {
"signal_text": "Waiting for data...",
"favorite": None,
"win_rate_str": None,
"checks": {},
"up_line": "",
"down_line": "",
}
if self.state.up_token and self.state.down_token:
up = self.state.up_token
down = self.state.down_token
vwap_window = self.config.strategy.vwap_window_sec
up_vwap = self.calc.calc_vwap(self.calc.get_trades_in_window(up.trades, vwap_window))
down_vwap = self.calc.calc_vwap(self.calc.get_trades_in_window(down.trades, vwap_window))
up_dev = self.calc.calc_deviation(up.last_price, up_vwap)
down_dev = self.calc.calc_deviation(down.last_price, down_vwap)
mom_window = self.config.strategy.momentum_window_sec
up_mom = self.calc.calc_momentum(up.trades, up.last_price, window=mom_window)
down_mom = self.calc.calc_momentum(down.trades, down.last_price, window=mom_window)
time_minutes = time_left / 60.0
span = self.config.market.interval_minutes
time_bin = int((span - 1) - time_minutes)
time_bin = max(0, min(time_bin, span - 1))
if up.last_price > down.last_price:
fav_name = "UP"
fav_price = up.last_price
fav_dev = up_dev
fav_mom = up_mom
else:
fav_name = "DOWN"
fav_price = down.last_price
fav_dev = down_dev
fav_mom = down_mom
base_wr = self.winrate_table.get_winrate(fav_price, time_bin, span)
wr_str = f"{base_wr:.1f}%" if base_wr else None
min_price = self.config.strategy.min_price
max_price = self.config.strategy.max_price
min_elapsed = self.config.strategy.min_elapsed_sec
min_dev = self.config.strategy.min_deviation_pct
max_dev = self.config.strategy.max_deviation_pct
no_entry_cutoff = self.config.strategy.no_entry_before_end_sec
elapsed_sec = self.config.market.duration_sec - time_left
price_ok = min_price <= fav_price <= max_price
time_ok = elapsed_sec >= min_elapsed
dev_ok = fav_dev > min_dev and fav_dev < max_dev
mom_ok = fav_mom is not None and fav_mom > 5
time_cutoff_ok = time_left > no_entry_cutoff
if not time_cutoff_ok:
signal = f"🚫 NO ENTRY (< {no_entry_cutoff}s left)"
elif price_ok and time_ok and dev_ok and mom_ok:
signal = f"✅ BUY {fav_name}"
elif fav_price >= 0.70 and time_ok:
if not mom_ok:
signal = "🟡 ALMOST (need Mom>0%)"
elif fav_dev >= max_dev:
signal = f"🟡 ALMOST (Dev≥{max_dev}%)"
else:
signal = "🟡 ALMOST (need dev)"
elif not time_ok:
signal = f"⏳ WAIT (elapsed<{min_elapsed}s)"
elif not price_ok:
signal = "⏳ WAIT (P not in range)"
elif not dev_ok:
signal = (
f"⏳ WAIT (Dev≥{max_dev}%)"
if fav_dev >= max_dev
else f"⏳ WAIT (Dev<{min_dev}%)"
)
elif not mom_ok:
signal = "⏳ WAIT (Mom≤0%)"
else:
signal = "⏳ WAIT"
strategy = {
"signal_text": signal,
"favorite": f"{fav_name} ({fav_price:.3f})",
"win_rate_str": wr_str,
"time_bin": time_bin,
"checks": {
"price": price_ok,
"time": time_ok,
"dev": dev_ok,
"mom": mom_ok,
"time_cutoff": time_cutoff_ok,
},
"up_line": f"{up.last_price:.3f} | Dev {up_dev:+.1f}% | Mom {up_mom if up_mom is not None else 0:.2f}%",
"down_line": f"{down.last_price:.3f} | Dev {down_dev:+.1f}% | Mom {down_mom if down_mom is not None else 0:.2f}%",
}
s = self.state
btc_age = time.time() - s.btc_last_update if s.btc_last_update > 0 else None
btc_block: dict = {
"btc_current_price": s.btc_current_price,
"btc_anchor_price": s.btc_anchor_price,
"btc_connected": s.btc_connected,
"fresh_sec": btc_age,
"deviation_line": "",
}
if s.btc_current_price > 0 and s.btc_anchor_price > 0:
dev_abs = s.btc_current_price - s.btc_anchor_price
dev_pct = (dev_abs / s.btc_anchor_price) * 100 if s.btc_anchor_price else 0.0
btc_block["deviation_line"] = f"${dev_abs:+,.2f} ({dev_pct:+.3f}%)"
st = self.stats
bet = self.config.entry.bet_amount_usd
wr_str = f"{st.win_rate:.1f}%" if st.trade_count > 0 else None
trading: dict = {
"bet_usd": bet,
"markets_seen": st.markets_seen,
"trade_count": st.trade_count,
"win_rate_str": wr_str,
"total_pnl": st.total_pnl,
"position": None,
"recent_trades": [],
}
if st.position:
pos = st.position
if pos.token_name == "UP" and self.state.up_token:
current_price = self.state.up_token.best_bid or self.state.up_token.last_price
elif pos.token_name == "DOWN" and self.state.down_token:
current_price = self.state.down_token.best_bid or self.state.down_token.last_price
else:
current_price = pos.entry_price
unrealized = (pos.contracts * current_price) - (pos.contracts * pos.entry_price)
dd_price = max(0.0, pos.entry_price - pos.min_price_seen)
dd_pct = (dd_price / pos.entry_price * 100) if pos.entry_price > 0 else 0.0
dd_usd = dd_price * pos.contracts
trading["position"] = {
"token_name": pos.token_name,
"entry_price": pos.entry_price,
"contracts": pos.contracts,
"hedged": pos.hedged,
"current_price": current_price,
"unrealized_pnl": unrealized,
"max_dd_usd": dd_usd,
"max_dd_pct": dd_pct,
"min_price_seen": pos.min_price_seen,
}
for trade in st.trades[-5:][::-1]:
icon = "✅" if trade.won else "❌"
trading["recent_trades"].append({
"line": f"{icon} {trade.token_name} @ {trade.entry_price:.2f} → ${trade.pnl:+.2f}",
})
return {
"ts": now,
"header": header,
"strategy": strategy,
"up": token_block(self.state.up_token),
"down": token_block(self.state.down_token),
"btc": btc_block,
"trading": trading,
"last_signal": self.last_signal,
}
# =============================================================================
# MAIN BOT
# =============================================================================
class LiveTradingBot:
def __init__(self):
self.config = None
self.state = MarketState()
self.stats = TradingStats()
self.dashboard: Dashboard = None
# Trading components
self.executor: OrderExecutor = None
self.hedge_mgr: HedgeManager = None
self.redeemer: Optional[AsyncAutoRedeemer] = None
self.telegram: TelegramNotifier = None
self.user_ws = None
self._user_ws_task: Optional[asyncio.Task] = None
# WebSocket
self.ws_client: WebSocketClient = None
# Chainlink BTC price
self.chainlink_client: ChainlinkPriceClient = None
self._chainlink_task: Optional[asyncio.Task] = None
# Control
self.running = False
self.tasks = []
self._sim_history: Optional[SimulationHistoryLogger] = None
self._web_snapshot_holder: Optional[WebSnapshotHolder] = None
async def initialize(self) -> bool:
# Load config
self.config = load_config()
errors = validate_config(self.config)
if errors:
for err in errors:
console.print(f"[red]Config error: {err}[/red]")
return False
im = self.config.market.interval_minutes
console.print(f"[bold cyan]🚀 BTC {im}-Min Live Trading Bot[/bold cyan]")
if self.config.simulation.enabled:
console.print("[bold yellow] SIMULATION MODE — no CLOB orders, no redeemer[/bold yellow]\n")
else:
console.print("[bold cyan] Real Trading + Dashboard[/bold cyan]\n")
console.print(f"[green]✓ Market: BTC up/down {im}m (slug btc-updown-{im}m-*)[/green]")
console.print(f"[green]✓ Config: P {self.config.strategy.min_price}-{self.config.strategy.max_price}, "
f"T≥{self.config.strategy.min_elapsed_sec}s, "
f"Dev {self.config.strategy.min_deviation_pct}%-{self.config.strategy.max_deviation_pct}%[/green]")
console.print(f"[green]✓ Bet: ${self.config.entry.bet_amount_usd}, "
f"Hedge: {'ON' if self.config.hedge.enabled else 'OFF'}[/green]")
if self.config.simulation.enabled:
if self.config.simulation.separate_trading_log:
self.stats = TradingStats(self.config.simulation.trading_log_path)
console.print(
f"[yellow]✓ Simulation stats: {self.config.simulation.trading_log_path}[/yellow]"
)
else:
console.print("[yellow]✓ Simulation stats: same file as live (trading_log.json)[/yellow]")
# Initialize trading components
console.print("[yellow]Initializing trading components...[/yellow]")
# Telegram
self.telegram = TelegramNotifier(
bot_token=self.config.telegram.bot_token,
chat_id=self.config.telegram.chat_id,
enabled=self.config.telegram.enabled
)
sim = self.config.simulation.enabled
if sim:
self.user_ws = None
self._user_ws_task = None
# Dummy credentials — CLOB is never initialized in simulation
pk = self.config.polymarket.private_key or "0x0000000000000000000000000000000000000000000000000000000000000001"
ak = self.config.polymarket.api_key or "sim"
sec = self.config.polymarket.api_secret or "sim"
ph = self.config.polymarket.api_passphrase or "sim"
self.executor = OrderExecutor(
private_key=pk,
api_key=ak,
api_secret=sec,
api_passphrase=ph,
clob_host=self.config.polymarket.clob_host,
chain_id=self.config.polymarket.chain_id,
signature_type=self.config.polymarket.signature_type,
funder_address=self.config.polymarket.funder_address or None,
user_ws=None,
simulation_mode=True,
)
console.print("[green]✓ Order executor: simulation (no CLOB)[/green]")
else:
# User WebSocket for order tracking (CRITICAL for fill confirmation!)
self.user_ws = UserWebSocket(
api_key=self.config.polymarket.api_key,
api_secret=self.config.polymarket.api_secret,
api_passphrase=self.config.polymarket.api_passphrase
)
self._user_ws_task = None
self.executor = OrderExecutor(
private_key=self.config.polymarket.private_key,
api_key=self.config.polymarket.api_key,
api_secret=self.config.polymarket.api_secret,
api_passphrase=self.config.polymarket.api_passphrase,
clob_host=self.config.polymarket.clob_host,
chain_id=self.config.polymarket.chain_id,
signature_type=self.config.polymarket.signature_type,
funder_address=self.config.polymarket.funder_address or None,
user_ws=self.user_ws,
simulation_mode=False,
)
if not await self.executor.initialize():
console.print("[red]Failed to initialize order executor[/red]")
return False
console.print("[yellow]Starting User WebSocket for order tracking...[/yellow]")
self._user_ws_task = asyncio.create_task(self.user_ws.connect())
await asyncio.sleep(1)
if self.user_ws.connected:
console.print("[green]User WebSocket connected - order tracking active[/green]")
logger.info("User WebSocket connected for order fill tracking")
else:
console.print("[yellow]User WebSocket connecting... (will retry)[/yellow]")
logger.warning("User WebSocket not yet connected")
# Hedge manager
hedge_config = HedgeManagerConfig(
enabled=self.config.hedge.enabled,
hedge_price=self.config.hedge.hedge_price,
order_type=self.config.hedge.order_type,
max_retries=self.config.hedge.max_retries,
retry_delay_ms=self.config.hedge.retry_delay_ms,
simulation_mode=sim,
)
self.hedge_mgr = HedgeManager(self.executor, hedge_config)
# Auto redeemer (live only)
if sim:
self.redeemer = None
console.print("[yellow]✓ Auto-redeemer: disabled in simulation[/yellow]")
else:
self.redeemer = AsyncAutoRedeemer(
private_key=self.config.polymarket.private_key,
rpc_url=self.config.polymarket.rpc_url,
funder_address=self.config.polymarket.funder_address or None,
signature_type=self.config.polymarket.signature_type,
interval_seconds=self.config.redeem.interval_seconds,
telegram_notifier=self.telegram
)
if sim:
jl = (self.config.simulation.history_jsonl_path or "").strip()
self._sim_history = SimulationHistoryLogger(
csv_path=self.config.simulation.history_csv_path,
jsonl_path=jl if jl else None,
summary_path=self.config.simulation.history_summary_path,
)
if self.stats.trades:
self._sim_history.write_summary(
[t.__dict__ for t in self.stats.trades],
self.stats.summary_dict(),
)
csv_p = self.config.simulation.history_csv_path or "(disabled)"
sum_p = self.config.simulation.history_summary_path or "(disabled)"
jl_p = jl or "(disabled)"
console.print(
f"[green]✓ Simulation analytics: CSV={csv_p} | JSONL={jl_p} | summary={sum_p}[/green]"
)
else:
self._sim_history = None
# Chainlink BTC price client
self.chainlink_client = ChainlinkPriceClient(
self.state, self.config.market.duration_sec
)
self._chainlink_task = asyncio.create_task(self.chainlink_client.connect())
console.print("[green]✓ Chainlink BTC/USD price feed starting...[/green]")
# Dashboard
self.dashboard = Dashboard(self.state, self.stats, self.config)
wd = self.config.web_dashboard
if wd.enabled:
self._web_snapshot_holder = WebSnapshotHolder()
ok = start_web_dashboard(wd.host, wd.port, self._web_snapshot_holder)
# 0.0.0.0 is not a valid host in a browser URL; use loopback for display.
if wd.host in ("0.0.0.0", ""):
open_url = f"http://127.0.0.1:{wd.port}/"
elif wd.host in ("::", "[::]"):
open_url = f"http://[::1]:{wd.port}/"
else:
open_url = f"http://{wd.host}:{wd.port}/"
if ok:
console.print(f"[green]✓ Web dashboard:[/green] [bold]{open_url}[/bold]")
console.print(
"[dim] Use http:// not https://. On Windows, if the page fails in your browser, "
"open this exact URL (avoid typing only “localhost”, which may use IPv6).[/dim]"
)
else:
console.print(
f"[yellow]⚠ Web dashboard did not start on port {wd.port} "
f"(in use by another app, or bind failed). Check logs.[/yellow]"
)
console.print("[green]✓ All components initialized[/green]\n")
return True
async def find_market(self) -> bool:
d = self.config.market.duration_sec
sfx = self.config.market.slug_infix
console.print(f"[yellow]Searching for active BTC {self.config.market.interval_minutes}-min market...[/yellow]")
async with _aiohttp_session() as session:
now = int(time.time())
current_window = (now // d) * d
for offset in [0, d, -d, 2 * d]:
target_ts = current_window + offset
expected_slug = f"btc-updown-{sfx}-{target_ts}"
try:
async with session.get(
f"{GAMMA_API}/markets?slug={expected_slug}",
proxy=_PROXY_URL or None,
) as resp:
if resp.status == 200:
markets = await resp.json()
if markets:
market = markets[0]
returned_slug = market.get("slug", "")
# CRITICAL: Verify API returned the market we asked for
if returned_slug != expected_slug:
logger.warning(f"API slug mismatch! Asked for {expected_slug}, got {returned_slug}")
continue
if not market.get("closed", True):
return await self._setup_market(market)
except Exception as e:
logger.debug(f"Error finding market {expected_slug}: {e}")
continue
return False
async def _setup_market(self, market: dict) -> bool:
console.print(f"[green]Found: {market.get('slug')}[/green]")
outcomes = market.get("outcomes", [])
tokens = market.get("clobTokenIds", [])
if isinstance(outcomes, str):
outcomes = json.loads(outcomes)
if isinstance(tokens, str):
tokens = json.loads(tokens)
up_token_id = None
down_token_id = None
# Use exact index lookup like reference implementation
try:
up_index = outcomes.index("Up") if "Up" in outcomes else None
down_index = outcomes.index("Down") if "Down" in outcomes else None
if up_index is not None and up_index < len(tokens):
up_token_id = tokens[up_index]
if down_index is not None and down_index < len(tokens):
down_token_id = tokens[down_index]
except (ValueError, IndexError):
pass
# Fallback to contains-based matching
if not up_token_id or not down_token_id:
for i, outcome in enumerate(outcomes):
if i < len(tokens):
outcome_lower = str(outcome).lower()
if not up_token_id and "up" in outcome_lower:
up_token_id = tokens[i]
elif not down_token_id and "down" in outcome_lower:
down_token_id = tokens[i]
# Last resort fallback
if not up_token_id and len(tokens) >= 1:
up_token_id = tokens[0]
if not down_token_id and len(tokens) >= 2:
down_token_id = tokens[1]
if not up_token_id or not down_token_id:
return False
end_str = market.get("end_date_iso") or market.get("endDate", "")
try:
end_time = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
end_timestamp = end_time.timestamp()
except:
end_timestamp = time.time() + self.config.market.duration_sec
slug = market.get("slug", "")
self.state.market_id = market.get("id", "")
self.state.condition_id = market.get("conditionId", "")
self.state.slug = slug
self.state.end_time = end_timestamp
self.state.up_token = TokenData(token_id=up_token_id, name="Up")
self.state.down_token = TokenData(token_id=down_token_id, name="Down")
self.state.connected = False
# Log token assignments for debugging
logger.info(f"Market tokens assigned:")
logger.info(f" Slug: {slug}")
logger.info(f" End Time: {end_str} (timestamp: {end_timestamp})")
logger.info(f" UP token: {up_token_id[:40]}...")
logger.info(f" DOWN token: {down_token_id[:40]}...")
self.stats.new_market(self.state.slug)
self.hedge_mgr.clear() # Reset hedge state for new market
if self.user_ws:
self.user_ws.clear_token_fills() # Reset WS fill buffer for new market
# BTC anchor is now auto-managed by ChainlinkPriceClient
# It detects interval boundaries from Chainlink timestamps independently
return True
def _simulation_log_entry(
self,
token_name: str,
avg_price: float,
contracts: int,
total_cost: float,
) -> None:
if not self._sim_history or not self.config.simulation.enabled:
return
pos = self.stats.position
hedged = bool(pos and pos.hedged)
self._sim_history.log_open(
market_slug=self.state.slug,
token_name=token_name,
contracts=contracts,
avg_price=avg_price,
total_cost=total_cost,
cumulative_realized_pnl=self.stats.total_pnl,
hedged=hedged,
trade_number=len(self.stats.trades) + 1,
)
signal_logger.info(
f" [SIM] History OPEN logged | realized PnL before exit: ${self.stats.total_pnl:+.4f}"
)
def _simulation_log_close(self, record: TradeRecord, hedged_was: bool) -> None:
if not self._sim_history or not self.config.simulation.enabled:
return
n = len(self.stats.trades)
self._sim_history.log_close(
record,
cumulative_pnl=self.stats.total_pnl,
total_closed=n,
win_rate_pct=self.stats.win_rate,
hedged=hedged_was,
)
self._sim_history.write_summary(
[t.__dict__ for t in self.stats.trades],
self.stats.summary_dict(),
)
s = self.stats.summary_dict()
signal_logger.info(
f" [SIM] History CLOSE logged | trade PnL ${record.pnl:+.4f} | "
f"cumulative ${s['total_pnl_usd']:+.4f} | WR {s['win_rate_pct']:.2f}% ({n} closed)"
)
async def execute_entry(self, side: str):
"""Execute entry order (live CLOB or simulation)."""
if not self.stats.can_enter():
signal_logger.info(f"SIGNAL IGNORED: {side} - cannot enter (already in position)")
return
# Defensive time cutoff check (race condition guard)
time_left = max(0, self.state.end_time - time.time())
no_entry_cutoff = self.config.strategy.no_entry_before_end_sec
if time_left < no_entry_cutoff:
signal_logger.info(
f"SIGNAL BLOCKED: {side} - too close to market end "
f"({time_left:.0f}s left < {no_entry_cutoff}s cutoff)"
)
logger.warning(f"Entry blocked: {time_left:.0f}s left < {no_entry_cutoff}s cutoff")
return
if side == "BUY_UP":
token = self.state.up_token
token_name = "UP"
opposite_token = self.state.down_token
else:
token = self.state.down_token
token_name = "DOWN"
opposite_token = self.state.up_token
if not token or not opposite_token:
signal_logger.warning(f"SIGNAL IGNORED: {side} - token data missing")
return
# Log full signal snapshot
signal_logger.info("=" * 60)
signal_logger.info(
"TRADE SIGNAL TRIGGERED (SIMULATION)" if self.config.simulation.enabled else "TRADE SIGNAL TRIGGERED"
)
signal_logger.info(f" Time: {datetime.now().isoformat()}")
signal_logger.info(f" Market: {self.state.slug}")
signal_logger.info(f" Signal: {side}")
signal_logger.info(f" Token: {token_name}")
time_left = max(0, self.state.end_time - time.time())
dur = self.config.market.duration_sec
span = self.config.market.interval_minutes
elapsed_sec = dur - time_left
time_bin = int((span - 1) - time_left / 60)
time_bin = max(0, min(time_bin, span - 1))
signal_logger.info(f" Elapsed: {elapsed_sec:.0f}s | Remaining: {time_left:.0f}s | Bin: {time_bin}")
# Calculate all indicators for both tokens
calc = self.dashboard.calc
vwap_window = self.config.strategy.vwap_window_sec
mom_window = self.config.strategy.momentum_window_sec
for label, tk in [("UP", self.state.up_token), ("DOWN", self.state.down_token)]:
if not tk:
signal_logger.info(f" {label}: no data")
continue
vwap = calc.calc_vwap(calc.get_trades_in_window(tk.trades, vwap_window))
dev = calc.calc_deviation(tk.last_price, vwap)
zscore = calc.calc_zscore(tk.trades, tk.last_price, window=5)
mom = calc.calc_momentum(tk.trades, tk.last_price, window=mom_window)
mom_str = f"{mom:+.2f}%" if mom is not None else "N/A"
signal_logger.info(f" --- {label} ---")
signal_logger.info(f" Price: LAST={tk.last_price:.4f} BID={tk.best_bid:.4f} ASK={tk.best_ask:.4f}")
signal_logger.info(f" VWAP {vwap_window}s: {vwap:.4f} | Deviation: {dev:+.2f}%")
signal_logger.info(f" Z-Score 5s: {zscore:+.2f} | Momentum {mom_window}s: {mom_str}")
signal_logger.info(f" Trades: {tk.trade_count} | Volume: {tk.volume_total:.0f}")
signal_logger.info(f" Buy Vol: {tk.volume_buy:.0f} | Sell Vol: {tk.volume_sell:.0f}")
# Win rate
up = self.state.up_token
down = self.state.down_token
if up and down:
fav_price = up.last_price if up.last_price > down.last_price else down.last_price
wr = self.dashboard.winrate_table.get_winrate(
fav_price, time_bin, self.config.market.interval_minutes
)
signal_logger.info(f" Win Rate: {wr:.1f}%" if wr else " Win Rate: N/A")
# Strategy conditions snapshot
signal_logger.info(f" Config: min_price={self.config.strategy.min_price}, "
f"max_price={self.config.strategy.max_price}, "
f"min_elapsed={self.config.strategy.min_elapsed_sec}s, "
f"dev_range={self.config.strategy.min_deviation_pct}%-{self.config.strategy.max_deviation_pct}%, "
f"no_entry_cutoff={self.config.strategy.no_entry_before_end_sec}s")
# Chainlink BTC/USD
s = self.state
if s.btc_current_price > 0 and s.btc_anchor_price > 0:
btc_dev_abs = s.btc_current_price - s.btc_anchor_price
btc_dev_pct = (btc_dev_abs / s.btc_anchor_price) * 100
signal_logger.info(f" BTC Chainlink: ${s.btc_current_price:,.2f} (anchor: ${s.btc_anchor_price:,.2f})")
signal_logger.info(f" BTC Deviation: ${btc_dev_abs:+,.2f} ({btc_dev_pct:+.4f}%)")
else:
signal_logger.info(f" BTC Chainlink: N/A")
signal_logger.info("=" * 60)
logger.info(f"Executing entry: {token_name}")
exec_config = ExecutionConfig(
bet_amount_usd=self.config.entry.bet_amount_usd,
price_offset=self.config.entry.price_offset,
max_retries=self.config.entry.max_retries,
retry_delay_ms=self.config.entry.retry_delay_ms,
fill_timeout_ms=self.config.entry.fill_timeout_ms,
min_contracts=self.config.entry.min_contracts,
min_order_usd=self.config.entry.min_order_usd,
max_entry_price=self.config.entry.max_entry_price
)
result = await self.executor.execute_entry(
token_id=token.token_id,
config=exec_config,
websocket_price=token.best_ask # Для ПОКУПКИ нужен ASK! Мы платим продавцам.
)
if result.success:
self.stats.record_entry(
token_name=token_name,
token_id=token.token_id,
opposite_token_id=opposite_token.token_id,
price=result.avg_price,
contracts=result.contracts_filled,
market_slug=self.state.slug
)
self._simulation_log_entry(
token_name, result.avg_price, result.contracts_filled, result.total_cost
)
self.dashboard.entry_flash = True
# Log successful entry
signal_logger.info(
"ENTRY EXECUTED SUCCESSFULLY (SIMULATED)"
if self.config.simulation.enabled
else "ENTRY EXECUTED SUCCESSFULLY"
)
signal_logger.info(f" Token: {token_name}")
signal_logger.info(f" Contracts: {result.contracts_filled}")
signal_logger.info(f" Avg Price: {result.avg_price:.4f}")
signal_logger.info(f" Total Cost: ${result.total_cost:.2f}")
signal_logger.info(f" Attempts: {result.attempts}")
signal_logger.info("-" * 40)
await self.telegram.notify_entry(
side=token_name,
price=result.avg_price,
contracts=result.contracts_filled,
cost=result.total_cost,
retries=result.attempts,
interval_minutes=self.config.market.interval_minutes,
simulation=self.config.simulation.enabled,
)
logger.info(f"Entry complete: {result.contracts_filled} @ {result.avg_price:.3f}")
# === PLACE GTD HEDGE ORDER ===
if self.config.hedge.enabled:
self.hedge_mgr.set_position(
opposite_token_id=opposite_token.token_id,
contracts=result.contracts_filled
)
hedge_result = await self.hedge_mgr.place_gtd_hedge()
if hedge_result.success:
self.dashboard.hedge_flash = True
hedge_cost = hedge_result.contracts * hedge_result.price
hsim = "🎮 [SIMULATION]\n" if self.config.simulation.enabled else ""
await self.telegram.send_message(
f"{hsim}"
f"🛡️ Hedge Order Placed (GTD)\n"
f"📦 {hedge_result.contracts} contracts @ ${hedge_result.price}\n"
f"💰 Cost: ${hedge_cost:.2f}\n"
f"🔖 Order ID: {hedge_result.order_id[:20]}...\n"
f"📋 Status: LIVE (passive)\n"
f"🔄 Attempts: {hedge_result.attempts}"
)
# Register WebSocket handler for hedge fills
self._register_hedge_ws_handler()
logger.info(f"GTD hedge placed: {hedge_result.contracts} @ ${hedge_result.price}")
else:
await self.telegram.send_message(
f"⚠️ Hedge Failed\n"
f"❌ {hedge_result.error}\n"
f"🔄 Attempts: {hedge_result.attempts}"
)
logger.error(f"Hedge failed: {hedge_result.error}")
else:
signal_logger.error(f"ENTRY FAILED: {result.error}")
signal_logger.info(f" Attempts: {result.attempts}")
signal_logger.info("-" * 40)
logger.error(f"Entry failed: {result.error}")
# ============================================================
# КРИТИЧНО: Если был таймаут - НЕ делаем retry (двойная покупка!)
# Вместо этого проверяем через WebSocket - может ордер исполнился
# ============================================================
if result.was_timeout:
signal_logger.error("🛑 TIMEOUT: Checking WebSocket for fills...")
logger.warning("Timeout detected — starting WS recovery")
recovered = False
if self.user_ws and self.user_ws.connected:
recovery_timeout = self.config.entry.ws_recovery_timeout_sec
signal_logger.info(f" Checking WS for fills on {token.token_id[:30]}...")
signal_logger.info(f" Recovery timeout: {recovery_timeout}s")
fill_data = await self.user_ws.wait_for_fills_on_token(
token_id=token.token_id,
timeout=recovery_timeout
)
if fill_data and fill_data["contracts"] > 0:
# ==============================
# RECOVERY: Order DID execute!
# ==============================
recovered = True
rec_contracts = fill_data["contracts"]
rec_price = fill_data["avg_price"]
rec_cost = fill_data["total_cost"]
signal_logger.info("=" * 60)
signal_logger.info("✅ TIMEOUT RECOVERY: Position found via WebSocket!")
signal_logger.info(f" Contracts: {rec_contracts}")
signal_logger.info(f" Avg Price: {rec_price:.4f}")
signal_logger.info(f" Total Cost: ${rec_cost:.2f}")
signal_logger.info(f" Fills: {len(fill_data['fills'])}")
signal_logger.info("=" * 60)
logger.info(f"Timeout recovery: {rec_contracts} @ {rec_price:.4f}")
# Record position as if entry succeeded
self.stats.record_entry(
token_name=token_name,
token_id=token.token_id,
opposite_token_id=opposite_token.token_id,
price=rec_price,
contracts=rec_contracts,
market_slug=self.state.slug
)
self._simulation_log_entry(
token_name, rec_price, rec_contracts, rec_cost
)
self.dashboard.entry_flash = True
await self.telegram.send_message(
f"🔄 Timeout Recovery!\n"
f"Order filled despite HTTP timeout.\n"
f"📊 {token_name} {rec_contracts} @ ${rec_price:.4f}\n"
f"💰 Cost: ${rec_cost:.2f}\n"
f"Market: {self.state.slug}"
)
await self.telegram.notify_entry(
side=token_name,
price=rec_price,
contracts=rec_contracts,
cost=rec_cost,
retries=result.attempts,
interval_minutes=self.config.market.interval_minutes,
simulation=self.config.simulation.enabled,
)
# Place hedge (normal flow)
if self.config.hedge.enabled:
self.hedge_mgr.set_position(
opposite_token_id=opposite_token.token_id,
contracts=rec_contracts
)
hedge_result = await self.hedge_mgr.place_gtd_hedge()
if hedge_result.success:
self.dashboard.hedge_flash = True
hedge_cost = hedge_result.contracts * hedge_result.price
hsim2 = "🎮 [SIMULATION]\n" if self.config.simulation.enabled else ""
await self.telegram.send_message(
f"{hsim2}"
f"🛡️ Hedge Order Placed (GTD)\n"
f"📦 {hedge_result.contracts} contracts @ ${hedge_result.price}\n"
f"💰 Cost: ${hedge_cost:.2f}\n"
f"🔖 Order ID: {hedge_result.order_id[:20]}...\n"
f"📋 Status: LIVE (passive)\n"
f"🔄 Attempts: {hedge_result.attempts}"
)
self._register_hedge_ws_handler()
logger.info(f"GTD hedge placed after recovery: {hedge_result.contracts} @ ${hedge_result.price}")
else:
await self.telegram.send_message(
f"⚠️ Hedge Failed (after recovery)\n"
f"❌ {hedge_result.error}"
)
else:
signal_logger.info(" WS recovery: no fills found")
else:
signal_logger.warning(" WS not connected — cannot recover")
if not recovered:
# No fill found — block entry (original behavior)
self.stats.block_entry("Network timeout - no fill detected via WS. Blocking re-entry.")
signal_logger.error("🛑 ENTRY BLOCKED: Timeout + no WS fill detected")
await self.telegram.send_message(
f"⚠️ TIMEOUT — No Fill Detected\n"
f"Order status unknown after timeout.\n"
f"WebSocket recovery found nothing.\n"
f"Re-entry blocked.\n"
f"Market: {self.state.slug}"
)
def _register_hedge_ws_handler(self):
"""Register WebSocket handler to track hedge order fills."""
if not self.user_ws:
logger.warning("User WebSocket not available for hedge tracking")
return
hedge_order_id = self.hedge_mgr.hedge_order_id
if not hedge_order_id:
return
original_on_trade = self.user_ws._on_trade
async def _hedge_trade_handler(data: dict):
"""Handle trade events and check for hedge fills."""
# Call original handler first
if original_on_trade:
await original_on_trade(data)
# Check if this trade is for our hedge order
# GTD orders are maker orders, so check maker_order_id
trade_order_id = data.get("maker_order_id", "") or data.get("taker_order_id", "")
status = data.get("status", "")
if trade_order_id == hedge_order_id and status == "MATCHED":
size = int(float(data.get("size", 0)))
price = float(data.get("price", 0))
self.hedge_mgr.on_hedge_fill(size, price)
pos = self._position if hasattr(self, '_position') else None
filled = self.hedge_mgr._position.hedge_contracts_filled if self.hedge_mgr._position else 0
total = self.hedge_mgr._position.contracts if self.hedge_mgr._position else 0
if self.hedge_mgr.is_hedged:
# Fully filled
self.stats.record_hedge(filled, price)
self.dashboard.hedge_flash = True
await self.telegram.send_message(
f"✅ Hedge FULLY Filled!\n"
f"📦 {filled} contracts @ ${price}\n"
f"🛡️ Position fully protected"
)
logger.info(f"Hedge fully filled: {filled} contracts")
else:
# Partial fill
await self.telegram.send_message(
f"🛡️ Hedge Partial Fill\n"
f"📦 +{size} contracts @ ${price}\n"
f"📊 Progress: {filled}/{total}"
)
logger.info(f"Hedge partial fill: +{size}, total {filled}/{total}")
self.user_ws._on_trade = _hedge_trade_handler
logger.info(f"Registered hedge fill handler for order {hedge_order_id[:20]}...")
async def check_market_end(self):
"""Close position at market end."""
pos = self.stats.position
if not pos:
return
time_left = self.state.end_time - time.time()
if time_left <= 10: # 10 seconds before end
hedged_was = pos.hedged
if pos.token_name == "UP" and self.state.up_token:
final_price = self.state.up_token.last_price
elif pos.token_name == "DOWN" and self.state.down_token:
final_price = self.state.down_token.last_price
else:
final_price = 0.5
# Log market end details
signal_logger.info("=" * 60)
signal_logger.info("MARKET END - POSITION CLOSING")
signal_logger.info(f" Time: {datetime.now().isoformat()}")
signal_logger.info(f" Market: {self.state.slug}")
signal_logger.info(f" Position: {pos.token_name}")
signal_logger.info(f" Entry Price: {pos.entry_price:.4f}")
signal_logger.info(f" Final Price: {final_price:.4f}")
signal_logger.info(f" Contracts: {pos.contracts}")
signal_logger.info(f" Hedged: {pos.hedged}")
record = self.stats.close_position(final_price)
if record:
self._simulation_log_close(record, hedged_was)
status = "✅ WIN" if record.won else "❌ LOSS"
signal_logger.info(f" Result: {'WIN' if record.won else 'LOSS'}")
signal_logger.info(f" P&L: ${record.pnl:+.2f}")
signal_logger.info(f" Max Drawdown: -{record.max_drawdown_abs:.4f} (-{record.max_drawdown_pct:.2f}%)")
dd_usd = record.max_drawdown_abs * record.contracts
signal_logger.info(f" Max DD ($): -${dd_usd:.2f} (min price: {record.entry_price - record.max_drawdown_abs:.4f})")
signal_logger.info(f" Total Trades: {len(self.stats.trades)}")
signal_logger.info(f" Session Stats: W={sum(1 for r in self.stats.trades if r.won)} / L={sum(1 for r in self.stats.trades if not r.won)}")
signal_logger.info(f" Total P&L: ${sum(r.pnl for r in self.stats.trades):+.2f}")
signal_logger.info("=" * 60)
logger.info(f"Position closed: {status}, PnL: ${record.pnl:+.2f}")
async def run_session(self):
"""Run single market session with dashboard."""
# Start WebSocket
self.ws_client = WebSocketClient(self.state)
ws_task = asyncio.create_task(self.ws_client.connect())
await asyncio.sleep(1)
# Track running order task (для non-blocking execution)
order_task: Optional[asyncio.Task] = None
try:
with Live(self.dashboard.render(), refresh_per_second=4, console=console) as live:
while self.running:
# Update dashboard (никогда не блокируется)
live.update(self.dashboard.render())
if self._web_snapshot_holder:
self._web_snapshot_holder.set(self.dashboard.build_web_snapshot())
# Check for entry signal - запускаем в отдельном task
if self.stats.can_enter() and self.dashboard.last_signal:
if order_task is None or order_task.done():
signal = self.dashboard.last_signal
self.dashboard.last_signal = ""
order_task = asyncio.create_task(self._safe_execute_entry(signal))
# Check if order completed
if order_task and order_task.done():
try:
order_task.result() # Получаем исключения если были
except Exception as e:
logger.error(f"Order task error: {e}")
order_task = None
# Track drawdown while in position
if self.stats.position:
pos = self.stats.position
if pos.token_name == "UP" and self.state.up_token:
self.stats.update_drawdown(self.state.up_token.last_price)
elif pos.token_name == "DOWN" and self.state.down_token:
self.stats.update_drawdown(self.state.down_token.last_price)
# Check market end (быстрая операция - не выносим в task)
await self.check_market_end()
# Market ended?
if time.time() > self.state.end_time:
console.print("\n[yellow]Market ended![/yellow]")
break
await asyncio.sleep(0.25)
finally:
# Cancel any running order tasks
for task in [order_task]:
if task and not task.done():
task.cancel()
try:
await task
except:
pass
# Graceful WebSocket shutdown
await self.ws_client.stop_graceful()
try:
ws_task.cancel()
await ws_task
except:
pass
# Stop User WebSocket for order tracking
if self.user_ws:
await self.user_ws.disconnect()
if self._user_ws_task:
try:
self._user_ws_task.cancel()
await self._user_ws_task
except:
pass
async def _safe_execute_entry(self, signal: str):
"""Execute entry in separate task with error handling."""
try:
await self.execute_entry(signal)
except Exception as e:
logger.error(f"Entry execution error: {e}")
signal_logger.error(f"ENTRY ERROR: {e}")
# GTD hedge is placed immediately after entry (no polling needed)
# Fills are tracked via WebSocket _register_hedge_ws_handler()
async def run(self):
"""Main run loop."""
if not await self.initialize():
return
self.running = True
redeemer_task = None
if self.redeemer is not None:
redeemer_task = asyncio.create_task(self.redeemer.run_loop())
sim_note = ""
if self.config.simulation.enabled:
sim_note = "🎮 SIMULATION MODE — no real orders\n"
await self.telegram.send_message(
f"{sim_note}"
f"🤖 Bot Started\n"
f"Strategy: ${self.config.entry.bet_amount_usd} per trade\n"
f"Hedge: {'enabled' if self.config.hedge.enabled else 'disabled'}"
)
try:
while self.running:
# Find market
if not await self.find_market():
console.print("[red]No market found. Waiting 30s...[/red]")
await asyncio.sleep(30)
continue
console.print("\n[bold green]Starting session...[/bold green]\n")
await self.run_session()
console.print("[yellow]Waiting 5s for next market...[/yellow]")
await asyncio.sleep(5)
except KeyboardInterrupt:
console.print("\n[yellow]Stopping...[/yellow]")
finally:
self.running = False
if self.redeemer is not None:
self.redeemer.stop()
if redeemer_task is not None:
try:
redeemer_task.cancel()
await redeemer_task
except Exception:
pass
# Gracefully close Chainlink RTDS WebSocket
if self.chainlink_client:
await self.chainlink_client.disconnect()
if self._chainlink_task:
try:
self._chainlink_task.cancel()
await self._chainlink_task
except:
pass
await self.telegram.send_message("🛑 Bot stopped")
await self.telegram.close()
console.print("[green]Bot stopped.[/green]")
async def main():
bot = LiveTradingBot()
loop = asyncio.get_event_loop()
def shutdown():
bot.running = False
if sys.platform != "win32":
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, shutdown)
await bot.run()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass