Multi-Sport Betting Analytics Dashboard

Lesson 2 of 2

Building a Live Sports Betting Dashboard

Estimated time: 10 minutes

Building a Live Sports Betting Dashboard

Dashboard Architecture

Three components:

  1. Data Ingestion: Fetch odds from multiple sources every 30 seconds
  2. Aggregation: Normalize and store in a database
  3. Visualization: Real-time UI showing odds side-by-side

Backend: Express + SQLite

const express = require('express');
const sqlite3 = require('sqlite3');
const app = express();

const db = new sqlite3.Database(':memory:');

// Create tables
db.run(`
  CREATE TABLE IF NOT EXISTS markets (
    id TEXT PRIMARY KEY,
    sport TEXT,
    title TEXT,
    yes_price REAL,
    no_price REAL,
    updated_at TIMESTAMP
  )
`);

db.run(`
  CREATE TABLE IF NOT EXISTS price_history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    market_id TEXT,
    yes_price REAL,
    no_price REAL,
    timestamp TIMESTAMP,
    FOREIGN KEY(market_id) REFERENCES markets(id)
  )
`);

app.get('/api/markets', (req, res) => {
  db.all('SELECT * FROM markets WHERE sport = ?', [req.query.sport || 'NFL'], (err, rows) => {
    res.json(rows);
  });
});

app.get('/api/markets/:id/history', (req, res) => {
  db.all(
    'SELECT * FROM price_history WHERE market_id = ? ORDER BY timestamp DESC LIMIT 100',
    [req.params.id],
    (err, rows) => {
      res.json(rows);
    }
  );
});

app.listen(3000);

Data Ingestion Loop

const fetch = require('node-fetch');

async function pollAllSports() {
  const sports = ['NFL', 'NBA', 'NHL', 'FIFA'];
  
  for (const sport of sports) {
    const kalshiMarkets = await fetchKalshiMarkets(sport);
    const polymarketMarkets = await fetchPolymarketMarkets(sport);
    
    const aggregated = aggregateByTitle(kalshiMarkets, polymarketMarkets);
    
    for (const market of aggregated) {
      db.run(
        'INSERT OR REPLACE INTO markets (id, sport, title, yes_price, no_price, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
        [
          market.id,
          sport,
          market.title,
          market.yes_price,
          market.no_price,
          new Date()
        ]
      );
      
      db.run(
        'INSERT INTO price_history (market_id, yes_price, no_price, timestamp) VALUES (?, ?, ?, ?)',
        [market.id, market.yes_price, market.no_price, new Date()]
      );
    }
  }
}

// Poll every 30 seconds
setInterval(pollAllSports, 30000);

Frontend: React Dashboard

import React, { useState, useEffect } from 'react';

function SportsBoard() {
  const [markets, setMarkets] = useState([]);
  const [sport, setSport] = useState('NFL');

  useEffect(() => {
    const interval = setInterval(async () => {
      const res = await fetch(`/api/markets?sport=${sport}`);
      const data = await res.json();
      setMarkets(data);
    }, 5000);

    return () => clearInterval(interval);
  }, [sport]);

  return (
    <div>
      <select onChange={(e) => setSport(e.target.value)}>
        <option>NFL</option>
        <option>NBA</option>
        <option>NHL</option>
        <option>FIFA</option>
      </select>

      <table>
        <thead>
          <tr>
            <th>Event</th>
            <th>Yes Price</th>
            <th>No Price</th>
            <th>Movement</th>
          </tr>
        </thead>
        <tbody>
          {markets.map(m => (
            <tr key={m.id}>
              <td>{m.title}</td>
              <td>{m.yes_price.toFixed(2)}</td>
              <td>{m.no_price.toFixed(2)}</td>
              <td><MovementIndicator marketId={m.id} /></td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function MovementIndicator({ marketId }) {
  const [movement, setMovement] = useState(null);

  useEffect(() => {
    fetch(`/api/markets/${marketId}/history`)
      .then(r => r.json())
      .then(history => {
        if (history.length >= 2) {
          const oldest = history[history.length - 1];
          const newest = history[0];
          const change = ((newest.yes_price - oldest.yes_price) / oldest.yes_price) * 100;
          setMovement(change.toFixed(1));
        }
      });
  }, [marketId]);

  return movement ? (
    <span style={{ color: movement > 0 ? 'green' : 'red' }}>
      {movement > 0 ? '↑' : '↓'} {Math.abs(movement)}%
    </span>
  ) : null;
}

export default SportsBoard;

Value Bet Detection

Find discrepancies between market odds and your model:

function findValueBets(markets, myProbabilities) {
  return markets
    .filter(m => {
      const marketProb = 1 / (m.yes_price / 100);
      const myProb = myProbabilities[m.id];
      const discrepancy = Math.abs(marketProb - myProb);
      return discrepancy > 0.05;  // 5% edge
    })
    .map(m => ({
      market: m.title,
      market_prob: 1 / (m.yes_price / 100),
      my_prob: myProbabilities[m.id],
      expected_value: m.yes_price * myProbabilities[m.id] - 1
    }));
}

Next: Historical Analysis

Analyze win rates, ROI, and Kelly Criterion for bet sizing.