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

197 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (NovemberApril).
**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.