Files
vyndr/specs/feature-1-1-odds-api.md
T

8.4 KiB
Raw Blame History

Feature 1.1 — Odds API Integration

Overview

Connect to The Odds API to pull live NBA and NCAAB player prop odds, normalize them into a consistent data shape, cache them in Redis (15-minute TTL), and expose them through two REST endpoints.

Dependencies

  • None (first feature to build)
  • Downstream consumers: Feature 1.3 (Prop Analysis Engine), Feature 2.2 (Line Movement)

External Service

  • Provider: The Odds API (https://the-odds-api.com)
  • Plan: Starter (free) — 500 credits/month
  • Rate limits: 500 credits/month. Each API call costs credits. Must track remaining quota via response headers (x-requests-remaining, x-requests-used).
  • Relevant endpoints:
    • GET /v4/sports/{sport}/events/{eventId}/odds — event odds
    • GET /v4/sports/{sport}/events — list today's events
  • Sports keys: basketball_nba, basketball_ncaab
  • Markets: player_points, player_rebounds, player_assists, player_threes, player_blocks, player_steals, player_points_rebounds_assists, player_turnovers
  • Bookmakers filter: draftkings, fanduel, betmgm

Endpoints

GET /api/odds/nba

Returns today's NBA player props normalized across all three books.

Query params:

Param Type Required Default Description
stat_type string no all Filter by stat (points, rebounds, etc.)
player string no none Filter by player name (partial match)
book string no all Filter by book (draftkings, fanduel, betmgm)

Response (200):

{
  "sport": "nba",
  "updated_at": "2026-03-21T14:30:00Z",
  "source": "cache | live",
  "quota_remaining": 412,
  "props": [
    {
      "player": "Nikola Jokic",
      "home_team": "DEN",
      "away_team": "LAL",
      "game_time": "2026-03-21T19:00:00Z",
      "stat_type": "points",
      "lines": [
        {
          "book": "draftkings",
          "line": 26.5,
          "over_odds": -110,
          "under_odds": -110,
          "fetched_at": "2026-03-21T14:28:00Z"
        },
        {
          "book": "fanduel",
          "line": 27.0,
          "over_odds": -105,
          "under_odds": -115,
          "fetched_at": "2026-03-21T14:28:00Z"
        }
      ]
    }
  ]
}

GET /api/odds/ncaab

Identical shape and params as /api/odds/nba but returns NCAAB props. Only active during college basketball season (NovemberApril).

Response (200): Same structure, "sport": "ncaab".

Off-season response (200):

{
  "sport": "ncaab",
  "updated_at": "2026-07-15T12:00:00Z",
  "source": "none",
  "quota_remaining": 500,
  "props": [],
  "message": "NCAAB is off-season. Props return in November."
}

Error Responses

Status When Body
400 Invalid query param value { "error": "Invalid stat_type: xyz" }
429 Odds API quota exhausted { "error": "Odds data temporarily unavailable. Try again later." }
502 Odds API returns non-200 or times out { "error": "Unable to fetch odds. Using last cached data.", "props": [...] }
503 Odds API down, no cache available { "error": "Odds service unavailable." }

Data Shape (Internal)

The normalized prop object used internally and stored in cache:

interface NormalizedProp {
  player: string;          // Full name as returned by Odds API
  home_team: string;       // 3-letter abbreviation (e.g., "DEN"). Player-to-team resolution deferred to Feature 1.2.
  away_team: string;       // 3-letter abbreviation
  game_time: string;       // ISO 8601
  stat_type: string;       // e.g., "points", "rebounds", "assists", "pra"
  book: string;            // "draftkings" | "fanduel" | "betmgm"
  line: number;            // e.g., 26.5
  over_odds: number;       // American odds, e.g., -110
  under_odds: number;      // American odds, e.g., -110
  fetched_at: string;      // ISO 8601 timestamp of fetch
}

Stat Type Mapping

Map Odds API market keys to our internal names:

Odds API Market Internal stat_type
player_points points
player_rebounds rebounds
player_assists assists
player_threes threes
player_blocks blocks
player_steals steals
player_points_rebounds_assists pra
player_turnovers turnovers

Caching Strategy

  • Store: Redis
  • Key pattern: odds:{sport}:{date} (e.g., odds:nba:2026-03-21)
  • TTL: 15 minutes
  • On cache hit: Return cached data with "source": "cache"
  • On cache miss: Fetch from Odds API, normalize, store, return with "source": "live"
  • On API failure with stale cache: Return stale cache with a warning header X-BetonBLK-Stale: true and "source": "cache" (do NOT error if stale data exists)
  • On API failure with no cache: Return 503

Rate Limit / Quota Management

  • Track quota via x-requests-remaining header from Odds API responses
  • Log a warning when remaining < 50
  • When remaining hits 0: serve only from cache, return 429 if no cache exists
  • Store quota state in Redis key: odds:quota:{month} with fields remaining and last_checked

Service Architecture

src/
├── services/
│   └── oddsService.js        # fetchOdds(sport), normalizeProps(raw), getQuota()
├── routes/
│   └── odds.js               # GET /api/odds/nba, GET /api/odds/ncaab
└── utils/
    └── oddsNormalizer.js      # Raw Odds API response → NormalizedProp[]
  • oddsService.js — Orchestrates: check cache -> fetch if miss -> normalize -> store -> return. Handles quota tracking.
  • oddsNormalizer.js — Pure function. Takes raw Odds API JSON, returns NormalizedProp[]. Filters to only our 3 books. Maps market keys to internal stat_types.
  • routes/odds.js — Thin route layer. Validates query params, calls service, formats response.

Acceptance Criteria

  1. GET /api/odds/nba returns normalized props from DraftKings, FanDuel, and BetMGM for today's NBA games
  2. GET /api/odds/ncaab returns the same for NCAAB (or off-season message when appropriate)
  3. Responses match the documented data shape exactly
  4. Props are cached in Redis with a 15-minute TTL
  5. Subsequent requests within 15 minutes return cached data (no Odds API call)
  6. If the Odds API fails but cache exists, stale cache is returned with a warning
  7. If the Odds API fails and no cache exists, a 503 is returned
  8. Quota remaining is tracked and logged; requests are blocked (429) when quota is 0
  9. stat_type, player, and book query filters work correctly
  10. Invalid query params return 400 with a clear error message

Test Plan

Unit Tests (oddsNormalizer.js)

  • Normalizes a raw Odds API response with multiple books and markets into NormalizedProp[]
  • Filters out books not in our set (e.g., ignores "bovada")
  • Maps all 8 market keys to correct internal stat_types
  • Handles missing/null odds gracefully (skips that line, doesn't crash)
  • Returns empty array for empty input

Unit Tests (oddsService.js)

  • Returns cached data when cache is fresh (no API call made)
  • Calls Odds API when cache is expired/missing
  • Stores normalized data in Redis after successful fetch
  • Returns stale cache on API failure
  • Returns 503-appropriate error when API fails and no cache
  • Tracks quota from response headers
  • Blocks fetches when quota is 0

Integration Tests

  • Full request cycle: GET /api/odds/nba with mocked Odds API -> verify response shape
  • Cache behavior: first call fetches, second call within 15min serves cache
  • Query param filtering: stat_type, player, book each filter correctly
  • Error scenario: Odds API 500 with warm cache -> returns stale data
  • Error scenario: Odds API 500 with cold cache -> returns 503

Open Questions

  • Team abbreviation mapping: Does The Odds API return standard 3-letter abbreviations, or do we need a mapping table? (Verify during implementation by inspecting a real response.)
  • Prop availability: Not all markets may be available for every game/player. Normalizer should handle partial data gracefully.