8.4 KiB
8.4 KiB
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 oddsGET /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 (November–April).
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: trueand"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-remainingheader 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 fieldsremainingandlast_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
GET /api/odds/nbareturns normalized props from DraftKings, FanDuel, and BetMGM for today's NBA gamesGET /api/odds/ncaabreturns the same for NCAAB (or off-season message when appropriate)- Responses match the documented data shape exactly
- Props are cached in Redis with a 15-minute TTL
- Subsequent requests within 15 minutes return cached data (no Odds API call)
- If the Odds API fails but cache exists, stale cache is returned with a warning
- If the Odds API fails and no cache exists, a 503 is returned
- Quota remaining is tracked and logged; requests are blocked (429) when quota is 0
stat_type,player, andbookquery filters work correctly- 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.