I've been running a Polymarket trading bot since April 2026. 3,292 predictions placed. And for the first two months, I was flying blind.
No dashboard. Just raw terminal logs scrolling past. P&L buried in JSON files. No way to know in real-time if the bot was winning, losing, stuck, or silently dead.
So I built a dashboard. And it nearly broke me.
This is everything I wish someone had told me before I started - the architecture decisions, the shocking edge cases, the WebSocket nightmares, the data problems that only appear at 2am when your bot is live.
What the Dashboard Needed to Do
Before writing a single line of code I listed the minimum viable features:
- Real-time P&L - know my current profit/loss without opening a spreadsheet
- Live trade signals - see what the bot is considering entering right now
- Position tracker - all open positions with entry price, current odds, unrealized P&L
- Bot health monitor - is the bot alive? when did it last fire? is the API connection healthy?
- Win rate stats - rolling win rate, average ROI per trade, total predictions
- Market odds display - live Polymarket odds for markets the bot is watching
Simple list. Absolute nightmare to build correctly.
The Stack I Chose
Frontend: React + Recharts + TailwindCSS
Backend: FastAPI (Python)
Real-time: WebSocket (native browser API + Python websockets library)
Database: SQLite (local) → PostgreSQL (production)
Data: Polymarket CLOB API + GAMMA API
Hosting: VPS (Ubuntu) + Nginx reverse proxy
Why FastAPI over Flask? Server-sent events and WebSocket support are first-class. With Flask I was fighting the framework. FastAPI just works.
Why SQLite first? Because premature optimization is real. SQLite handled 3,000+ trade records without breaking a sweat. I only moved to Postgres when I needed concurrent writes from multiple bot processes.
Shocking Problem #1: The WebSocket That Lies to You
This one cost me three days.
Polymarket's CLOB WebSocket connection appears to stay open even when it's dead. The readyState shows OPEN. No error fires. No disconnect event. The connection is just... silently delivering nothing.
I built my dashboard assuming that if the WebSocket was open, it was working. My odds display would show data frozen from 40 minutes ago while I assumed everything was live.
The fix is a heartbeat monitor - you have to implement it yourself:
import asyncio
import websockets
import json
import time
class PolymarketWSClient:
def __init__(self, url: str, on_message, heartbeat_interval: int = 15):
self.url = url
self.on_message = on_message
self.heartbeat_interval = heartbeat_interval
self.last_message_time = time.time()
self.ws = None
self.connected = False
async def connect(self):
while True: # auto-reconnect loop
try:
async with websockets.connect(self.url) as ws:
self.ws = ws
self.connected = True
self.last_message_time = time.time()
print(f"[WS] Connected to {self.url}")
# Run message listener and heartbeat checker concurrently
await asyncio.gather(
self._listen(ws),
self._heartbeat_check(ws)
)
except Exception as e:
self.connected = False
print(f"[WS] Disconnected: {e}. Reconnecting in 5s...")
await asyncio.sleep(5)
async def _listen(self, ws):
async for message in ws:
self.last_message_time = time.time()
await self.on_message(json.loads(message))
async def _heartbeat_check(self, ws):
while True:
await asyncio.sleep(self.heartbeat_interval)
elapsed = time.time() - self.last_message_time
if elapsed > self.heartbeat_interval * 2:
print(f"[WS] No message for {elapsed:.0f}s — forcing reconnect")
await ws.close() # triggers reconnect in outer loop
return
The key insight: don't trust readyState. Trust message recency. If no message has arrived in 2× your expected interval, treat the connection as dead and reconnect.
Dashboard lesson: always show a "last updated" timestamp next to every live data panel. Users (including yourself) need to know how stale the data is.
Shocking Problem #2: P&L That Lies at Market Boundaries
This one is subtle and will drive you insane.
Polymarket markets resolve in batches. Between when a market closes and when it officially resolves on-chain, your position is in a limbo state. The API still shows it as open. The price is frozen. But the outcome is effectively decided.
If you calculate P&L from position value alone, your dashboard will show wildly wrong numbers during this window — sometimes for hours.
from enum import Enum
from dataclasses import dataclass
from typing import Optional
class MarketStatus(Enum):
ACTIVE = "active"
CLOSED = "closed" # trading stopped, not yet resolved
RESOLVED = "resolved" # outcome confirmed on-chain
LIMBO = "limbo" # closed but resolution pending
@dataclass
class Position:
market_id: str
token_id: str
entry_price: float
size: float
current_price: float
market_status: MarketStatus
resolution_price: Optional[float] = None
def calculate_pnl(position: Position) -> dict:
"""
P&L calculation that handles market boundary states correctly.
"""
if position.market_status == MarketStatus.RESOLVED:
# Use resolution price, not current_price (which may be stale)
if position.resolution_price is None:
return {"pnl": None, "status": "awaiting_resolution_data"}
pnl = (position.resolution_price - position.entry_price) * position.size
return {
"pnl": round(pnl, 4),
"status": "resolved",
"is_final": True
}
elif position.market_status == MarketStatus.LIMBO:
# Show unrealized P&L but flag it as unreliable
unrealized = (position.current_price - position.entry_price) * position.size
return {
"pnl": round(unrealized, 4),
"status": "limbo_unreliable",
"warning": "Market closed — P&L pending on-chain resolution",
"is_final": False
}
elif position.market_status == MarketStatus.CLOSED:
# Same as limbo but cleaner
unrealized = (position.current_price - position.entry_price) * position.size
return {
"pnl": round(unrealized, 4),
"status": "closed_pending",
"is_final": False
}
else: # ACTIVE
unrealized = (position.current_price - position.entry_price) * position.size
return {
"pnl": round(unrealized, 4),
"status": "live",
"is_final": False
}
Dashboard lesson: never show a single P&L number without a status badge. Show whether it's live, limbo, or final. A number without context is worse than no number.
Shocking Problem #3: The Rate Limit Wall at Exactly the Wrong Moment
Polymarket's CLOB API has rate limits that seem generous until your dashboard and your bot are both hitting the API at the same time.
The bot is scanning for entry signals. The dashboard is polling for position updates. The dashboard is also refreshing odds for 12 watched markets. Suddenly you're at 429 Too Many Requests - and your bot misses an entry because the dashboard stole its API budget.
The fix is a shared rate-limited API client with a request queue:
import asyncio
from collections import deque
import time
class RateLimitedAPIClient:
"""
Single shared API client for both bot and dashboard.
Enforces rate limits across all consumers.
"""
def __init__(self, requests_per_second: float = 5.0):
self.rps = requests_per_second
self.min_interval = 1.0 / requests_per_second
self.last_request_time = 0.0
self.queue = deque()
self._lock = asyncio.Lock()
async def get(self, url: str, priority: str = "normal") -> dict:
"""
priority: 'bot' = high priority (jumps queue)
'normal' = dashboard polling (standard queue)
"""
async with self._lock:
now = time.monotonic()
wait = self.min_interval - (now - self.last_request_time)
if wait > 0:
await asyncio.sleep(wait)
self.last_request_time = time.monotonic()
# Make actual request (implement with aiohttp)
return await self._fetch(url)
async def _fetch(self, url: str) -> dict:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 429:
retry_after = int(response.headers.get('Retry-After', 10))
print(f"[API] Rate limited. Waiting {retry_after}s")
await asyncio.sleep(retry_after)
return await self._fetch(url) # retry once
return await response.json()
# Singleton — import this everywhere
api_client = RateLimitedAPIClient(requests_per_second=4.0)
Dashboard lesson: your dashboard is a second consumer of your API budget. Design for this from day one. The bot always gets priority. The dashboard polls on whatever's left.
Shocking Problem #4: Real-Time Charts That Eat Your Memory
I added a live P&L chart to the dashboard - a rolling line chart updating every second. Looked amazing. Worked perfectly.
For about 45 minutes.
Then the browser tab was using 800MB of RAM and the chart was lagging 3 seconds behind. The problem: I was pushing every data point into a React state array and never trimming it. After 2,700 data points (45 minutes × 60 seconds), Recharts was re-rendering a 2,700-point dataset on every tick.
import { useState, useEffect, useRef } from "react";
const MAX_POINTS = 300; // 5 minutes at 1s intervals
export function useLivePnLChart(websocket) {
const [chartData, setChartData] = useState([]);
const bufferRef = useRef([]);
useEffect(() => {
if (!websocket) return;
const handleMessage = (event) => {
const data = JSON.parse(event.data);
if (data.type !== "pnl_update") return;
const point = {
time: new Date().toLocaleTimeString(),
pnl: parseFloat(data.pnl.toFixed(4)),
};
// Update buffer
bufferRef.current = [
...bufferRef.current.slice(-(MAX_POINTS - 1)),
point
];
// Only update React state every 5 seconds to limit re-renders
// Use requestAnimationFrame for smooth updates
setChartData([...bufferRef.current]);
};
websocket.addEventListener("message", handleMessage);
return () => websocket.removeEventListener("message", handleMessage);
}, [websocket]);
return chartData;
}
And the throttled update pattern for charts:
import { useCallback, useRef } from "react";
export function useThrottledChartUpdate(setter, intervalMs = 2000) {
const lastUpdate = useRef(0);
const pending = useRef(null);
return useCallback((newData) => {
pending.current = newData;
const now = Date.now();
if (now - lastUpdate.current >= intervalMs) {
setter(newData);
lastUpdate.current = now;
pending.current = null;
}
}, [setter, intervalMs]);
}
Dashboard lesson: cap your data buffer. 300 points is plenty for a live chart. Beyond that you're paying memory cost for data nobody is reading.
Shocking Problem #5: The Bot Dies and the Dashboard Doesn't Know
This one is genuinely scary. Your bot process crashes at 3am. The dashboard still shows the last known state - positions, P&L, odds - all frozen but looking completely normal. You wake up, check the dashboard, think everything is fine. The bot has been dead for 6 hours.
The fix is a heartbeat system between the bot and the dashboard backend:
# In your bot process - send heartbeat every 10 seconds
import asyncio
import aiohttp
import time
async def send_heartbeat(dashboard_url: str):
while True:
try:
payload = {
"timestamp": time.time(),
"status": "alive",
"active_positions": len(bot.get_positions()),
"last_trade_time": bot.last_trade_timestamp,
"uptime_seconds": bot.uptime()
}
async with aiohttp.ClientSession() as session:
await session.post(f"{dashboard_url}/api/heartbeat", json=payload)
except Exception as e:
print(f"[Heartbeat] Failed to send: {e}")
await asyncio.sleep(10)
# In your FastAPI backend - track heartbeat and expose health endpoint
from fastapi import FastAPI
from datetime import datetime
import time
app = FastAPI()
last_heartbeat = {"timestamp": None, "data": None}
@app.post("/api/heartbeat")
async def receive_heartbeat(payload: dict):
last_heartbeat["timestamp"] = time.time()
last_heartbeat["data"] = payload
return {"status": "received"}
@app.get("/api/bot-health")
async def bot_health():
if last_heartbeat["timestamp"] is None:
return {"status": "never_connected", "alive": False}
age = time.time() - last_heartbeat["timestamp"]
alive = age < 30 # dead if no heartbeat in 30 seconds
return {
"alive": alive,
"last_seen_seconds_ago": round(age, 1),
"status": "alive" if alive else "DEAD",
"last_data": last_heartbeat["data"]
}
In the React dashboard, poll /api/bot-health every 15 seconds and show a big red banner if alive === false. Not a subtle indicator. A big red banner you cannot miss.
Dashboard lesson: assume the bot will die unexpectedly. Design the dashboard to scream when it does, not silently show stale data.
Shocking Problem #6: Timezone Hell
Polymarket markets resolve at specific times. The CLOB API returns timestamps in UTC. Your local machine might be in a different timezone. Your VPS is probably in UTC. Your browser is in the user's local timezone.
I spent two hours debugging why a market that "should have resolved 3 hours ago" was still showing as active. It had resolved. The timestamps were just being compared in mixed timezones.
from datetime import datetime, timezone
def normalize_timestamp(ts) -> datetime:
"""
Always returns timezone-aware UTC datetime.
Handles: Unix timestamp (int/float), ISO string, naive datetime
"""
if isinstance(ts, (int, float)):
return datetime.fromtimestamp(ts, tz=timezone.utc)
if isinstance(ts, str):
# Handle ISO format with or without Z suffix
ts = ts.replace('Z', '+00:00')
dt = datetime.fromisoformat(ts)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
if isinstance(ts, datetime):
if ts.tzinfo is None:
return ts.replace(tzinfo=timezone.utc)
return ts
raise ValueError(f"Cannot normalize timestamp: {ts!r}")
def time_to_resolution(end_date_str: str) -> float:
"""Returns hours until resolution. Negative = already resolved."""
end = normalize_timestamp(end_date_str)
now = datetime.now(tz=timezone.utc)
delta = (end - now).total_seconds()
return delta / 3600
Dashboard lesson: pick UTC everywhere in your backend. Convert to local time only at the last moment in the frontend display layer. Never compare timestamps from different sources without normalizing first.
Shocking Problem #7: The Number That's Always Wrong
Your dashboard shows P&L as $0.30000000000000004.
JavaScript floating point. It will appear. It will look terrible. And it will appear in the most visible place on your dashboard.
// The problem
const pnl = 0.1 + 0.2; // → 0.30000000000000004
// The fix - always format before display
const formatPnL = (value) => {
const rounded = Math.round(value * 10000) / 10000;
const sign = rounded >= 0 ? "+" : "";
return `${sign}$${Math.abs(rounded).toFixed(4)}`;
};
// For percentages
const formatROI = (value) => {
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(2)}%`;
};
// For odds (0-1 scale → percentage display)
const formatOdds = (value) => {
return `${(value * 100).toFixed(1)}%`;
};
// Never let a raw float touch the DOM
// Every number: formatPnL(), formatROI(), or formatOdds() before render
Dashboard lesson: create formatting functions on day one and route every displayed number through them. Fixing floating point display after the fact means hunting down every raw number in your JSX. Do it right from the start.
The Architecture That Actually Works
After all of this, here's the architecture I settled on:
┌─────────────────────────────────────────────┐
│ React Dashboard │
│ P&L Chart | Positions | Bot Health | Odds │
└──────────────┬──────────────────────────────┘
│ WebSocket (live updates)
│ REST (initial load + polling)
┌──────────────▼──────────────────────────────┐
│ FastAPI Backend │
│ /api/positions /api/health /api/stats │
│ WS /ws/live-feed │
└──────────────┬──────────────────────────────┘
│
┌─────────┴──────────┐
│ │
┌────▼────┐ ┌──────▼──────┐
│ SQLite/ │ │ Polymarket │
│Postgres │ │ CLOB API │
│(history)│ │ (live data) │
└─────────┘ └─────────────┘
▲
│ heartbeat every 10s
┌──────────────┴──────────────────────────────┐
│ Trading Bot Process │
│ Scanner | Entry Logic | Position Manager │
└─────────────────────────────────────────────┘
The bot and the dashboard backend are separate processes. They communicate only through the database (for history) and the heartbeat endpoint (for health). This means if the dashboard crashes, the bot keeps running. If the bot crashes, the dashboard still shows historical data and screams that the bot is dead.
The 10 Dashboard Rules I Now Never Break
- Every live number shows its last-updated timestamp - no silent staleness
- Bot health is always visible - top of the page, big red/green indicator
- P&L has a status badge - live, limbo, resolved, or final
- WebSocket has a heartbeat - if no message in 30s, reconnect
- Dashboard never shares API budget with the bot - separate rate limiters
- All timestamps normalized to UTC at ingestion - convert to local only on display
- Chart data is capped at 300 points - buffer trimmed on every push
- Every number goes through a formatter - no raw floats in the DOM
- Bot and dashboard are separate processes - one dying doesn't kill the other
- Assume everything will fail at 3am - design every component for graceful degradation
What I'd Build Differently
If I started today:
- Use Grafana + InfluxDB for the charts from day one. Rolling my own charting was interesting but Grafana handles time-series data with zero memory issues.
- Add alerting before adding features - a Telegram or Discord bot that pings me when the bot dies is more valuable than a beautiful P&L chart.
- Start with a single-page static HTML dashboard - I overcomplicated the frontend with React when a simple HTML page with vanilla JS WebSocket would have been live in 2 hours.
Final Thought
Building a trading bot dashboard teaches you things that no tutorial prepares you for - because the problems only appear when real money is moving through real APIs in real time.
The WebSocket that silently dies. The P&L frozen at market close. The rate limit wall you hit because you forgot your dashboard is also an API consumer. The floating point number that makes your beautiful dashboard look broken.
Every one of these problems has a fix. But you have to get burned by them first.
My Polymarket profile if you want to see the bot's live results: @rowelly
All code here is production code from my live bot. Use it, break it, improve it.
Tags: #webdev #python #javascript #react #algotrading #polymarket #tradingbot #webSocket #fastapi #buildinpublic
Top comments (0)