DEV Community

Rust Engineer
Rust Engineer

Posted on

I Built a Real-Time Dashboard for My Polymarket Trading Bot - Here Are Problems Nobody Warned Me About

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
# 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"]
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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   │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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

  1. Every live number shows its last-updated timestamp - no silent staleness
  2. Bot health is always visible - top of the page, big red/green indicator
  3. P&L has a status badge - live, limbo, resolved, or final
  4. WebSocket has a heartbeat - if no message in 30s, reconnect
  5. Dashboard never shares API budget with the bot - separate rate limiters
  6. All timestamps normalized to UTC at ingestion - convert to local only on display
  7. Chart data is capped at 300 points - buffer trimmed on every push
  8. Every number goes through a formatter - no raw floats in the DOM
  9. Bot and dashboard are separate processes - one dying doesn't kill the other
  10. 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)