# 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):** ```json { "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 (November–April). **Response (200):** Same structure, `"sport": "ncaab"`. **Off-season response (200):** ```json { "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: ```typescript 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-VYNDR-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.