feat: Feature 1.1 — Odds API integration complete, 28 tests passing

This commit is contained in:
Kev
2026-03-21 08:31:15 -04:00
parent f70db389e2
commit 00409fd6cd
16 changed files with 6896 additions and 6 deletions
+196
View File
@@ -0,0 +1,196 @@
# 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-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.