197 lines
8.4 KiB
Markdown
197 lines
8.4 KiB
Markdown
# 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.
|