# 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.
