Sports Data Infrastructure

    The Anatomy of an Enterprise Odds Feed

    Understand the technical architecture of enterprise-grade odds feeds. Learn what separates professional-grade systems from basic feeds, including data…

    14 min read3,272 words
    Share
    TL;DR

    You can get a basic odds feed working in a weekend. An API that returns current odds for major sports. Connect it to your frontend, display some odds, users are happy.

    The Difference Between a Data Feed and an Enterprise Odds Feed

    You can get a basic odds feed working in a weekend. An API that returns current odds for major sports. Connect it to your frontend, display some odds, users are happy.

    But an enterprise odds feed is something different. It's the difference between a startup sportsbook and a $1B+ operator. It's the difference between "we're hoping this works" and "we know exactly what's happening every millisecond."

    An enterprise odds feed includes:

    • Data validation that catches corrupted data automatically
    • Redundancy so no single source failure impacts availability
    • Audit trails for regulatory compliance
    • Performance monitoring at every layer
    • Version control for API changes
    • Failover automation that happens in milliseconds
    • Historical data retention for litigation holds
    • Regional compliance handling

    Most vendors sell you a data feed. FairPlay, premium US sports publishers, and other enterprise operators sell you an odds infrastructure system.

    This guide walks through what's inside an enterprise odds feed.

    Layer 1: Data Ingestion and Normalization

    The Challenge

    You're connecting to 50+ bookmakers. Each has different API requirements:

    • Bet365: REST API with specific authentication
    • Betfair: WebSocket feed with custom protocol
    • FanDuel: gRPC with protobuf messages
    • DraftKings: Webhook-based push
    • PointsBet: Custom binary protocol

    Each sends odds in different formats:

    • Decimal (1.50, 2.50, 3.00): European standard
    • Fractional (1/2, 3/2, 2/1): UK traditional
    • Moneyline (-200, +250, +300): US standard
    • Asian Handicap (0.75, 1.25): Asian standard

    Latencies vary wildly:

    • Bet365 updates every 100ms during live play
    • Some regional bookmakers update every 5 seconds
    • Some only update on request (polling)

    The Solution: Connector Framework

    Enterprise odds feeds use a standardized connector framework:

    type OddsConnector interface {
      // Authenticate and initialize connection
      Connect(ctx context.Context, credentials Credentials) error
    
      // Listen for incoming odds updates
      ListenForUpdates(ctx context.Context) <-chan OddsUpdate
    
      // Gracefully disconnect
      Disconnect(ctx context.Context) error
    
      // Health check
      IsHealthy() bool
    
      // What's your latency SLA?
      GetLatencySLA() time.Duration
    
      // What markets do you provide?
      GetAvailableMarkets() []string
    
      // Maximum requests per second
      GetRateLimit() int
    }
    
    // Each bookmaker gets a specific implementation
    type Bet365Connector struct{ /* ... */ }
    type BetfairConnector struct{ /* ... */ }
    type FanDuelConnector struct{ /* ... */ }
    // etc.
    

    Each connector implementation handles:

    • Authentication (API keys, OAuth, mTLS, custom)
    • Protocol translation (REST → normalized JSON, WebSocket → normalized JSON, etc.)
    • Error handling (timeouts, rate limits, corrupted data)
    • Reconnection logic (exponential backoff if connection fails)

    Data Normalization

    All connectors output a standardized format:

    {
      "id": "event_bet365_12345",
      "source": "bet365",
      "timestamp": "2026-03-23T15:30:45.123456Z",
      "eventId": "soccer_league_mancity_liverpool",
      "eventName": "Manchester City vs Liverpool",
      "sport": "soccer",
      "markets": [
        {
          "id": "match_winner",
          "name": "Match Winner",
          "type": "winner",
          "suspended": false,
          "outcomes": [
            {
              "id": "outcome_mancity",
              "name": "Manchester City",
              "odds": {
                "decimal": 1.95,
                "fractional": "19/20",
                "moneyline": -1950,
                "hongKong": 0.95,
                "probability": 0.5128
              }
            }
          ]
        }
      ]
    }
    

    This standardization means your downstream systems never have to think about which bookmaker sent this data. They work with a single canonical format.

    Layer 2: Data Validation and Quality

    The Problem

    Raw data from bookmakers is often corrupt or nonsensical:

    1. Impossible odds: Market shows "Manchester City: 1.10, Draw: 1.05, Liverpool: 1.05"—the implied probabilities sum to 115% (impossible)
    2. State transitions: Market is CLOSED then suddenly receives a price update
    3. Staleness detection: Price hasn't moved in 30 seconds when it normally updates every 100ms
    4. Outlier detection: Odds jump 40% in a single update (might indicate data corruption)
    5. Duplicate detection: Same odds received twice from different sources
    6. Missing markets: Expected markets aren't present for this event

    The Solution: Validation Pipeline

    type ValidationRule interface {
      Validate(update OddsUpdate, context ValidationContext) error
    }
    
    type validationContext struct {
      previousUpdate   OddsUpdate
      marketMetadata   MarketMetadata
      eventMetadata    EventMetadata
      sourceHistory    []OddsUpdate
    }
    
    // Rule 1: Implied probability validation
    type ImpliedProbabilityRule struct{}
    
    func (r *ImpliedProbabilityRule) Validate(update OddsUpdate, ctx ValidationContext) error {
      for _, market := range update.Markets {
        totalProb := 0.0
        for _, outcome := range market.Outcomes {
          totalProb += outcome.Odds.Probability
        }
    
        // Allow 5% margin for bookmaker vig/overround
        if totalProb < 0.95 || totalProb > 1.05 {
          return fmt.Errorf("Invalid market: sum of implied probs = %.2f", totalProb)
        }
      }
      return nil
    }
    
    // Rule 2: Price movement validation
    type PriceMovementRule struct {
      maxSingleChangePercent float64
    }
    
    func (r *PriceMovementRule) Validate(update OddsUpdate, ctx ValidationContext) error {
      if ctx.previousUpdate == nil {
        return nil // No previous update to compare
      }
    
      for _, market := range update.Markets {
        for _, outcome := range market.Outcomes {
          prevOutcome := ctx.previousUpdate.GetOutcome(outcome.ID)
          if prevOutcome != nil {
            change := math.Abs(outcome.Odds.Decimal - prevOutcome.Odds.Decimal) / prevOutcome.Odds.Decimal
            if change > r.maxSingleChangePercent {
              return fmt.Errorf("Suspicious odds movement: %.1f%%", change*100)
            }
          }
        }
      }
      return nil
    }
    
    // Rule 3: Staleness detection
    type StalenessRule struct {
      maxStalenessDuration time.Duration
    }
    
    func (r *StalenessRule) Validate(update OddsUpdate, ctx ValidationContext) error {
      if ctx.previousUpdate == nil {
        return nil
      }
    
      timeSinceLastUpdate := update.Timestamp.Sub(ctx.previousUpdate.Timestamp)
      if timeSinceLastUpdate > r.maxStalenessDuration {
        return fmt.Errorf("Stale data detected: no update for %v", timeSinceLastUpdate)
      }
      return nil
    }
    
    // Validator runs all rules
    type Validator struct {
      rules []ValidationRule
    }
    
    func (v *Validator) Validate(update OddsUpdate, ctx ValidationContext) []error {
      var errors []error
      for _, rule := range v.rules {
        if err := rule.Validate(update, ctx); err != nil {
          errors = append(errors, err)
        }
      }
      return errors
    }
    

    When validation fails, the system doesn't reject the data silently. Instead:

    1. Log the failure with full context (which source, which market, what rule failed)
    2. Alert operations if critical (e.g., a major bookmaker is sending invalid data)
    3. Optional: use previous good state or use data from secondary sources
    4. Store in quarantine for later investigation
    func (v *Validator) ValidateWithFallback(update OddsUpdate, ctx ValidationContext) OddsUpdate {
      errors := v.Validate(update, ctx)
    
      if len(errors) == 0 {
        return update // All checks pass
      }
    
      // Validation failed. Try to recover.
      log.Error("Validation failed", "source", update.Source, "errors", errors)
    
      // Option 1: Use previous good state
      if ctx.previousUpdate != nil && timesSinceLast < 5*time.Second {
        metrics.Increment("odds.validation_fallback_previous")
        return ctx.previousUpdate
      }
    
      // Option 2: Use data from other sources
      alternativeSources := ctx.GetAlternativeSources()
      if len(alternativeSources) > 0 {
        metrics.Increment("odds.validation_fallback_alternative")
        return alternativeSources[0]
      }
    
      // Option 3: Mark as questionable but publish anyway
      metrics.Increment("odds.validation_published_questionable")
      update.Metadata = append(update.Metadata, "validation_failed")
      return update
    }
    

    Layer 3: Aggregation and Best Odds Selection

    Now you have validated odds from 50+ sources. The question: which odds do you show to users?

    Simple Approach: Best Odds

    Show users the best odds available:

    func getBestOdds(event Event, market Market) BestOdds {
      var best BestOdds
    
      for _, outcome := range market.Outcomes {
        maxOdds := 0.0
        bestSource := ""
    
        for _, source := range getAllSources() {
          odds := source.GetOdds(event.ID, market.ID, outcome.ID)
          if odds.Decimal > maxOdds {
            maxOdds = odds.Decimal
            bestSource = source.Name
          }
        }
    
        best[outcome.ID] = {
          odds:   maxOdds,
          source: bestSource
        }
      }
    
      return best
    }
    

    Advantage: Users always get the best possible odds.

    Disadvantage: Odds come from different sources. "Manchester City at 1.95 from Bet365, Draw at 3.50 from Betfair, Liverpool at 3.25 from FanDuel" creates an impossible bet (implied probability >100%). Users can't actually place this bet.

    Advanced Approach: Consistent Market Selection

    Pick one bookmaker as the "primary" for each market, then apply adjustments from other sources:

    type MarketConsensus struct {
      primarySource   string      // "bet365"
      adjustments     map[string] float64  // "betfair": +0.05, "fanduel": -0.02
    }
    
    func getConsensusOdds(market Market, consensus MarketConsensus) Odds {
      // Start with primary source
      primaryOdds := getPrimarySource(market, consensus.primarySource)
    
      // Apply consensus adjustments
      adjusted := primaryOdds
      for source, adjustment := range consensus.adjustments {
        weight := 0.1 // Other sources contribute 10% to adjustment
        adjusted += (getSourceOdds(market, source) - primaryOdds) * weight
      }
    
      return adjusted
    }
    

    Advantage: Odds are always consistent (sum to ~100% implied probability).

    Disadvantage: Odds might not be the absolute best available.

    Publisher-Specific Approach

    Different publishers have different requirements:

    • premium US sports publishers: "Always show bet365 odds to maintain partnership"
    • La Gazzetta: "Show odds from Italian sportsbooks first, others as fallback"
    • MARCA: "Show best odds, but no more than 3 sportsbooks per market"

    This is configured per publisher:

    type PublisherOddsPolicy struct {
      publisherID          string
      preferredSources     []string      // order matters
      maxSourcesPerMarket  int
      fallbackBehavior     FallbackBehavior
      bestOddsOnly         bool
    }
    
    func getPublisherOdds(publisher Publisher, market Market) PublisherOdds {
      policy := getPolicy(publisher.ID)
    
      if policy.bestOddsOnly {
        return getBestOdds(market)
      } else {
        return getPreferredSourceOdds(market, policy.preferredSources)
      }
    }
    

    Layer 4: Redundancy and Failover

    Failover Strategy

    If a bookmaker's feed goes offline, the system detects this and switches to alternative sources automatically.

    type SourceHealthChecker struct {
      sources        []OddsSource
      lastHealthy    map[string]time.Time
      healthCheckInterval time.Duration
    }
    
    func (h *SourceHealthChecker) CheckHealth(ctx context.Context) {
      ticker := time.NewTicker(h.healthCheckInterval)
      defer ticker.Stop()
    
      for {
        select {
        case <-ctx.Done():
          return
        case <-ticker.C:
          for _, source := range h.sources {
            if isHealthy := source.HealthCheck(); isHealthy {
              h.lastHealthy[source.Name] = time.Now()
            } else {
              timeSinceHealthy := time.Since(h.lastHealthy[source.Name])
              if timeSinceHealthy > 5*time.Minute {
                // Source has been unhealthy for 5 minutes
                alerts.SendAlert(fmt.Sprintf("Source %s unhealthy for 5 minutes", source.Name))
              }
            }
          }
        }
      }
    }
    
    func (h *SourceHealthChecker) GetHealthySources() []OddsSource {
      var healthy []OddsSource
      for _, source := range h.sources {
        if time.Since(h.lastHealthy[source.Name]) < 2*time.Minute {
          healthy = append(healthy, source)
        }
      }
      return healthy
    }
    

    Cascading Fallback

    If you don't have odds from source A, use source B. If not B, use C, etc.

    func getMarketOdds(market Market, cascadeOrder []string) Odds {
      for _, sourceName := range cascadeOrder {
        if source := findSource(sourceName); source != nil && source.IsHealthy() {
          if odds := source.GetOdds(market); odds != nil {
            return odds
          }
        }
      }
      return nil // No source available
    }
    

    Layer 5: Audit and Compliance

    Every odds change is logged for regulatory purposes:

    type OddsAuditLog struct {
      ID                 string
      Timestamp          time.Time
      EventID            string
      MarketID           string
      SourceID           string
      PreviousOdds       Odds
      NewOdds            Odds
      ValidatorStatus    string // "passed", "failed_but_published", "rejected"
      UserID             string // if accessed via API
      PublisherID        string // if served to publisher
      IPAddress          string // for fraud detection
      ValidationErrors   []string
    }
    
    func logOddsUpdate(update OddsUpdate, auditInfo AuditInfo) {
      for _, market := range update.Markets {
        for _, outcome := range market.Outcomes {
          auditLog := OddsAuditLog{
            ID:              generateID(),
            Timestamp:       time.Now(),
            EventID:         update.EventId,
            MarketID:        market.ID,
            SourceID:        update.Source,
            PreviousOdds:    getPreviousOdds(market.ID, outcome.ID),
            NewOdds:         outcome.Odds,
            ValidatorStatus: auditInfo.ValidationStatus,
            PublisherID:     auditInfo.PublisherID,
            IPAddress:       auditInfo.IPAddress,
          }
          db.InsertAuditLog(auditLog)
        }
      }
    }
    

    This audit log serves multiple purposes:

    1. Regulatory compliance: Auditors can see every odds change, its source, and validation status
    2. Dispute resolution: If a user claims they placed a bet at 3.00 and lost, you can verify the odds history
    3. Fraud detection: If odds are being manipulated or tampered with, the audit log shows it

    Layer 6: API Layer

    The odds feed exposes multiple APIs for different use cases:

    API 1: Real-Time Streaming (WebSocket)

    const ws = new WebSocket('wss://odds.fairplay.com/stream');
    ws.send(JSON.stringify({
      type: 'subscribe',
      eventIds: ['event_123', 'event_456'],
      formats: ['decimal'],
      includeMetadata: true
    }));
    
    ws.onmessage = (event) => {
      const update = JSON.parse(event.data);
      // { timestamp, eventId, markets: [...] }
    };
    

    API 2: REST Polling

    GET /api/v2/odds/events/{eventId}
    GET /api/v2/odds/events/{eventId}/markets/{marketId}
    GET /api/v2/odds/markets/best?events=event_123,event_456
    

    API 3: Historical Query

    GET /api/v2/odds/history/{eventId}/{marketId}?from=2026-03-23T00:00:00Z&to=2026-03-24T00:00:00Z
    

    Returns: time series of all odds for this market over the period.

    The Cost of an Enterprise Odds Feed

    Why does FairPlay charge more than basic data providers?

    • Data ingestion from 50+ sources: $5K-10K/month in bandwidth and processing
    • Validation and quality layer: $2K-5K/month in compute
    • Redundancy and geographic distribution: $10K-20K/month in infrastructure
    • Compliance and audit logging: $2K-5K/month in storage and processing
    • Support team: $15K-25K/month for operations and customer support
    • R&D for improvements: $10K-20K/month

    Total: $44K-85K per month per operator

    This is why enterprise odds feeds cost $15K-50K/month, not $1,000/month.

    The cheap "data feeds" you find online are:

    • Single-source (no redundancy)
    • No validation layer
    • No compliance infrastructure
    • No support
    • Unreliable (when the bookmaker changes their API, your feed breaks)

    They work until they don't. Then you're scrambling to rebuild.

    Disaster Recovery and Business Continuity

    Enterprise odds feeds need comprehensive disaster recovery:

    Recovery Time Objective (RTO) vs Recovery Point Objective (RPO)

    RTO: How long until service is restored?

    • Acceptable RTO for production: <5 minutes
    • Acceptable RTO for compliance: <1 hour

    RPO: How much data loss is acceptable?

    • Acceptable RPO for odds: Zero (no missed updates)
    • Acceptable RPO for compliance: 24 hours (previous day's backup)

    FairPlay achieves:

    • RTO: <2 minutes (automatic failover)
    • RPO: Zero (replicated in real-time to 2 data centers)

    Disaster Scenarios and Recovery

    Scenario 1: Primary Data Center Outage

    T=0: Primary data center loses power
      - Monitoring detects loss of connectivity
      - Automated failover kicks in (no human intervention)
    
    T=30s: Traffic rerouted to secondary data center
      - All API clients automatically redirect
      - Odds continue flowing from backup providers
      - Users see <1 second interruption
    
    T=5m: Status page updated
      - Team investigates root cause
      - Power company confirms estimated restoration time
    
    T=2h: Primary data center back online
      - Data replication catches up
      - Primary data center gradually takes traffic back
      - Team monitors for any issues
    

    Scenario 2: Provider Feed Corruption

    T=0: Bet365 feed sends invalid data
      - Validation layer detects impossible odds
      - Odds update is rejected
      - Fallback to secondary providers
    
    T=1s: No user impact
      - Odds update came from 20 other providers
      - User sees valid odds from Betfair, FanDuel, etc.
      - Monitoring alerts on Bet365 corruption
    
    T=5m: Bet365 issue investigation
      - FairPlay operations team calls Bet365
      - Bet365 confirms: API issue, being fixed
      - ETA: 15 minutes
    
    T=20m: Bet365 recovered
      - Feed is healthy again
      - Validation passes
      - Seamlessly reintegrated into aggregation
    
    T=0 user impact: None (provider redundancy)
    

    Scenario 3: Database Corruption

    T=0: Time-series database (TimescaleDB) detects corruption
      - Backup is restored from 1 hour ago
      - <100 odds updates are lost from the corrupted period
      - Historical queries show 1-hour gap
    
    T=10m: Compliance notified
      - Need to document the recovery action
      - Investigation into root cause (hardware failure?)
    
    T=1h: Hardware replaced
      - Database is healthy
      - Gap is documented in audit trail
      - Regulators are informed (if required)
    
    Impact: 100 odds updates lost, 1-hour historical gap, documented compliance action
    

    Backup and Recovery Procedures

    Daily backup schedule:
      02:00 UTC: Full database backup to S3
      06:00 UTC: Test backup restore (verify data integrity)
      12:00 UTC: Incremental backup
      18:00 UTC: Incremental backup
    
    Weekly procedures:
      Sunday 00:00 UTC: Full backup to secondary region (cross-region)
      Tuesday 14:00 UTC: Disaster recovery drill (restore from weekly backup)
    
    Retention:
      Daily backups: Keep 7 days
      Weekly backups: Keep 52 weeks
      Monthly backups: Keep 7 years
    

    Performance Tuning

    Over time, odds feed performance can degrade. Here's how to maintain performance:

    Monitoring Degradation

    Query that runs in 100ms last month now runs in 500ms
    
    Questions to ask:
    1. Did data volume increase? (More events = slower queries)
    2. Did data retention increase? (More historical data = slower archival queries)
    3. Did a bad deployment happen? (New code introduced inefficiency)
    4. Did hardware resources change? (Suddenly slower?)
    5. Are we experiencing unusual query patterns? (Bot traffic?)
    

    Optimisation Techniques

    Technique 1: Index Optimisation

    -- Missing index on (event_id, timestamp) causes slow queries
    CREATE INDEX idx_odds_event_timestamp ON odds(event_id, timestamp DESC);
    
    -- Query that was slow is now fast
    SELECT * FROM odds
    WHERE event_id = '123'
    AND timestamp > NOW() - INTERVAL '1 day'
    ORDER BY timestamp DESC;
    

    Technique 2: Partitioning

    -- Instead of one large table, partition by date
    CREATE TABLE odds_2026_03 (
      id BIGSERIAL,
      event_id UUID,
      timestamp TIMESTAMPTZ,
      odds DECIMAL,
      PRIMARY KEY (id)
    ) PARTITION OF odds
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
    
    -- Queries on recent data are faster (smaller partition)
    

    Technique 3: Materialized Views

    -- Pre-compute expensive aggregations
    CREATE MATERIALIZED VIEW best_odds AS
    SELECT
      event_id,
      market_id,
      MAX(decimal_odds) as best_decimal,
      MAX(timestamp) as last_updated
    FROM odds
    GROUP BY event_id, market_id;
    
    -- Refresh every 1 minute
    REFRESH MATERIALIZED VIEW CONCURRENTLY best_odds;
    
    -- Queries hit materialized view (instant) instead of recalculating
    

    Client Implementation Best Practices

    If you're building a sportsbook using an odds feed:

    Error Handling

    async function fetchOddsWithFallback(eventId) {
      const maxRetries = 3;
      let lastError;
    
      for (let attempt = 1; attempt <= maxRetries; attempt++) {
        try {
          return await fetchOdds(eventId);
        } catch (error) {
          lastError = error;
          const delay = Math.pow(2, attempt) * 100; // 200ms, 400ms, 800ms
          await sleep(delay);
        }
      }
    
      // All retries failed, use cached odds or error handling
      const cached = await getCachedOdds(eventId);
      if (cached) {
        return { ...cached, warning: 'Using cached data' };
      }
    
      throw new Error(`Failed to fetch odds after ${maxRetries} attempts: ${lastError}`);
    }
    

    Validation

    function validateOddsUpdate(update, previousOdds) {
      // Check 1: Implied probability
      const impliedProbs = update.outcomes.map(o => 1 / o.odds);
      const sum = impliedProbs.reduce((a, b) => a + b);
      if (sum < 0.95 || sum > 1.05) {
        return { valid: false, reason: 'Invalid implied probability' };
      }
    
      // Check 2: Price movement
      if (previousOdds) {
        const maxChange = 0.3; // 30%
        for (let i = 0; i < update.outcomes.length; i++) {
          const change = Math.abs(update.outcomes[i].odds - previousOdds[i].odds) / previousOdds[i].odds;
          if (change > maxChange) {
            return { valid: false, reason: 'Suspicious price movement' };
          }
        }
      }
    
      return { valid: true };
    }
    

    Moving Forward: Understanding Your Odds Feed

    When evaluating any odds feed (FairPlay or competitor), ask:

    1. How many sources? Fewer than 5 = high risk.
    2. What validation happens? If "none," that's red flag.
    3. How is redundancy handled? Should be automatic, not manual.
    4. What audit logging? Should include every update and validation status.
    5. How many geographic locations? Fewer than 2 = single point of failure.
    6. What SLA? Should be 99.95%+ with SLA credits.
    7. How is support handled? Should include on-call for critical issues.
    8. What's the disaster recovery story? How quickly can they recover?
    9. What monitoring is included? Can you see performance metrics?
    10. How do they handle scaling? Can they grow with your business?

    FairPlay's odds feed checks all of these boxes:

    • 50+ sources for redundancy
    • Multi-layer validation with automatic fallback
    • Automatic redundancy across 4 data centers
    • Full audit logging for compliance
    • 99.99% uptime track record
    • Enterprise support with on-call coverage
    • <5 minute disaster recovery (RTO)
    • Zero data loss (RPO)
    • Real-time dashboards for monitoring
    • Infrastructure scales to 1B+ daily price changes

    This isn't just a data feed. It's the infrastructure that makes odds reliable at enterprise scale.


    Related Articles:

    Share

    Ready to explore BetTech for your business?

    Talk to the FairPlay team about how our platform can work for your business.

    Get Started

    Related Articles

    Explore More Insights