feat: Feature 1.1 — Odds API integration complete, 28 tests passing
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
+20
-5
@@ -1,21 +1,36 @@
|
|||||||
# BetonBLK — Build State
|
# BetonBLK — Build State
|
||||||
|
|
||||||
## Last Updated
|
## Last Updated
|
||||||
[Not yet started]
|
2026-03-21
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
Phase 1 — Foundation
|
Phase 1 — Foundation
|
||||||
|
|
||||||
## What Has Shipped
|
## What Has Shipped
|
||||||
Nothing yet. Project initialized.
|
|
||||||
|
### Feature 1.1 — Odds API Integration (COMPLETE)
|
||||||
|
- GET /api/odds/nba — live NBA player props from DraftKings, FanDuel, BetMGM
|
||||||
|
- GET /api/odds/ncaab — NCAAB props (with off-season detection)
|
||||||
|
- Normalizer: pairs Over/Under outcomes, maps 8 market types, filters to 3 books
|
||||||
|
- Redis cache: 15-min TTL, stale fallback on API failure
|
||||||
|
- Quota tracking via response headers, 429 when exhausted
|
||||||
|
- Query filters: stat_type, player (partial match), book
|
||||||
|
- 28 tests passing (18 unit, 10 integration)
|
||||||
|
- Known limitation: player-to-team assignment deferred to Feature 1.2 (uses home_team/away_team instead of team/opponent)
|
||||||
|
|
||||||
## What's Next
|
## What's Next
|
||||||
Feature 1.1 — Odds API Integration (no dependencies)
|
Feature 1.2 — NBA_API Stats Wrapper (no dependencies, can build now)
|
||||||
Feature 1.2 — NBA_API Stats Wrapper (no dependencies, can build parallel)
|
|
||||||
Feature 1.4 — Database Schema (no dependencies, can build parallel)
|
Feature 1.4 — Database Schema (no dependencies, can build parallel)
|
||||||
|
|
||||||
## Active Blockers
|
## Active Blockers
|
||||||
See BLOCKERS.md
|
See BLOCKERS.md
|
||||||
|
|
||||||
## Session Log
|
## Session Log
|
||||||
[No sessions yet]
|
|
||||||
|
### Session 1 — 2026-03-21
|
||||||
|
- Made live Odds API test call, documented raw response format in DECISIONS.md
|
||||||
|
- Built: oddsNormalizer.js, oddsService.js, routes/odds.js, teamMap.js, redis.js, app.js
|
||||||
|
- Wrote 28 tests (unit + integration), all passing
|
||||||
|
- Logged DECISION-001 (API response format) and DECISION-002 (credit conservation)
|
||||||
|
- Spec updated: home_team/away_team replaces team/opponent (API limitation)
|
||||||
|
- Credits used: 2 of 500 (498 remaining)
|
||||||
|
|||||||
+59
-1
@@ -10,4 +10,62 @@ Each decision follows this structure:
|
|||||||
- Consequences: [What this means going forward]
|
- Consequences: [What this means going forward]
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
[No decisions logged yet. First entries expected: hosting + auth provider.]
|
### DECISION-001: Odds API Raw Response Format (Feature 1.1)
|
||||||
|
- Date: 2026-03-21
|
||||||
|
- Context: Needed to verify actual Odds API response shape before writing normalizer. Made live test calls to `/v4/sports/basketball_nba/events` and `/v4/sports/basketball_nba/events/{id}/odds`.
|
||||||
|
|
||||||
|
**Raw response structure (verified):**
|
||||||
|
```
|
||||||
|
Event level:
|
||||||
|
- id: "a1158df1a3a21def58491807df167c6a"
|
||||||
|
- home_team: "Washington Wizards" (FULL NAME, not abbreviation)
|
||||||
|
- away_team: "Oklahoma City Thunder"
|
||||||
|
- commence_time: "2026-03-21T21:10:00Z" (ISO 8601 UTC)
|
||||||
|
|
||||||
|
Bookmaker level (nested under event.bookmakers[]):
|
||||||
|
- key: "fanduel" | "draftkings" | "betmgm"
|
||||||
|
- title: "FanDuel" | "DraftKings" (human-readable)
|
||||||
|
|
||||||
|
Market level (nested under bookmaker.markets[]):
|
||||||
|
- key: "player_points" (matches our expected market keys)
|
||||||
|
- last_update: "2026-03-21T12:17:04Z" (ISO 8601 UTC)
|
||||||
|
|
||||||
|
Outcome level (nested under market.outcomes[]):
|
||||||
|
- name: "Over" | "Under"
|
||||||
|
- description: "Shai Gilgeous-Alexander" (FULL PLAYER NAME)
|
||||||
|
- price: -110 (American odds, integer)
|
||||||
|
- point: 28.5 (the line)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key findings:**
|
||||||
|
1. Team names are full names ("Washington Wizards"), NOT 3-letter abbreviations. We need a mapping table.
|
||||||
|
2. Player names are in `description` field, full names.
|
||||||
|
3. Over/Under for the same player+line appear as separate outcome objects. Must pair them.
|
||||||
|
4. The API does NOT tell us which team a player belongs to. We only know home_team/away_team for the event. Player-to-team assignment requires roster data (Feature 1.2).
|
||||||
|
5. `markets` param accepts comma-separated values — can fetch all 8 prop markets in one API call per event.
|
||||||
|
|
||||||
|
**Quota headers (verified):**
|
||||||
|
- `x-requests-used`: cumulative credits used this month
|
||||||
|
- `x-requests-remaining`: credits left
|
||||||
|
- `x-requests-last`: credits consumed by this specific call (was 1)
|
||||||
|
|
||||||
|
- Decision:
|
||||||
|
1. Build a static NBA team name → 3-letter abbreviation mapping in utils.
|
||||||
|
2. Normalizer must pair Over/Under outcomes by player name + point value.
|
||||||
|
3. For Feature 1.1, set `team` to the full team name from the event. Player-to-team resolution deferred to Feature 1.2 integration.
|
||||||
|
4. Fetch all markets in a single call per event to conserve credits.
|
||||||
|
5. Use on-demand fetching only (not polling) — fetch from API only when a user request hits and cache is cold.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Could skip team abbreviations entirely — rejected because downstream features (Prop Engine, UI) need short team identifiers.
|
||||||
|
- Could try to resolve player→team via external lookup now — rejected because Feature 1.2 will provide this natively.
|
||||||
|
- Consequences:
|
||||||
|
- Need `src/utils/teamMap.js` with full name → abbreviation mapping.
|
||||||
|
- Normalizer groups Over+Under outcomes by `description` + `point`.
|
||||||
|
- Credit budget: ~1 credit per event per refresh. With 15-min cache + on-demand only, budget stays within 500/month for typical usage.
|
||||||
|
|
||||||
|
### DECISION-002: Credit Conservation Strategy (Feature 1.1)
|
||||||
|
- Date: 2026-03-21
|
||||||
|
- Context: Starter plan = 500 credits/month. Player props require per-event API calls (sport-level endpoint only supports main markets). ~10 NBA games/day.
|
||||||
|
- Decision: On-demand fetching only. Never poll. Cache aggressively at 15-min TTL. Batch all markets into one call per event. For a full NBA slate, one refresh = ~10 credits. At 15-min cache, even heavy usage stays under budget.
|
||||||
|
- Alternatives considered: Background polling every 15 min — rejected, would burn ~480 credits per game day.
|
||||||
|
- Consequences: First request after cache expires will be slower (live API call). Acceptable tradeoff for free tier.
|
||||||
|
|||||||
Generated
+5489
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "betonblk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"directories": {
|
||||||
|
"doc": "docs",
|
||||||
|
"test": "tests"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest --verbose",
|
||||||
|
"test:unit": "jest tests/unit --verbose",
|
||||||
|
"test:integration": "jest tests/integration --verbose"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/kev3109/betonblk.git"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/kev3109/betonblk/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/kev3109/betonblk#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"ioredis": "^5.10.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^30.3.0",
|
||||||
|
"supertest": "^7.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (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-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.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const oddsRoutes = require('./routes/odds');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/odds', oddsRoutes);
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { getOdds } = require('../services/oddsService');
|
||||||
|
const { MARKET_MAP, ALLOWED_BOOKS } = require('../utils/oddsNormalizer');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const VALID_STAT_TYPES = new Set(Object.values(MARKET_MAP));
|
||||||
|
const VALID_BOOKS = ALLOWED_BOOKS;
|
||||||
|
|
||||||
|
// NCAAB is in-season November through April
|
||||||
|
function isNcaabSeason() {
|
||||||
|
const month = new Date().getUTCMonth() + 1; // 1-indexed
|
||||||
|
return month >= 11 || month <= 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateQueryParams(query) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (query.stat_type && !VALID_STAT_TYPES.has(query.stat_type)) {
|
||||||
|
errors.push(`Invalid stat_type: ${query.stat_type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.book && !VALID_BOOKS.has(query.book)) {
|
||||||
|
errors.push(`Invalid book: ${query.book}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterProps(props, query) {
|
||||||
|
let filtered = props;
|
||||||
|
|
||||||
|
if (query.stat_type) {
|
||||||
|
filtered = filtered.filter((p) => p.stat_type === query.stat_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.player) {
|
||||||
|
const search = query.player.toLowerCase();
|
||||||
|
filtered = filtered.filter((p) => p.player.toLowerCase().includes(search));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.book) {
|
||||||
|
filtered = filtered.filter((p) => p.book === query.book);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group flat props into the response format: grouped by player+stat with nested lines
|
||||||
|
function groupProps(flatProps) {
|
||||||
|
const grouped = {};
|
||||||
|
|
||||||
|
for (const prop of flatProps) {
|
||||||
|
const key = `${prop.player}::${prop.stat_type}::${prop.game_time}`;
|
||||||
|
if (!grouped[key]) {
|
||||||
|
grouped[key] = {
|
||||||
|
player: prop.player,
|
||||||
|
home_team: prop.home_team,
|
||||||
|
away_team: prop.away_team,
|
||||||
|
game_time: prop.game_time,
|
||||||
|
stat_type: prop.stat_type,
|
||||||
|
lines: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
grouped[key].lines.push({
|
||||||
|
book: prop.book,
|
||||||
|
line: prop.line,
|
||||||
|
over_odds: prop.over_odds,
|
||||||
|
under_odds: prop.under_odds,
|
||||||
|
fetched_at: prop.fetched_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(grouped);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/nba', async (req, res) => {
|
||||||
|
const errors = validateQueryParams(req.query);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return res.status(400).json({ error: errors.join('; ') });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getOdds('nba');
|
||||||
|
const filtered = filterProps(result.props, req.query);
|
||||||
|
const props = groupProps(filtered);
|
||||||
|
|
||||||
|
if (result.stale) {
|
||||||
|
res.set('X-BetonBLK-Stale', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
sport: 'nba',
|
||||||
|
updated_at: result.updated_at,
|
||||||
|
source: result.source,
|
||||||
|
quota_remaining: result.quota_remaining,
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.statusCode || 500;
|
||||||
|
return res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/ncaab', async (req, res) => {
|
||||||
|
if (!isNcaabSeason()) {
|
||||||
|
return res.json({
|
||||||
|
sport: 'ncaab',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
source: 'none',
|
||||||
|
quota_remaining: null,
|
||||||
|
props: [],
|
||||||
|
message: 'NCAAB is off-season. Props return in November.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = validateQueryParams(req.query);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return res.status(400).json({ error: errors.join('; ') });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getOdds('ncaab');
|
||||||
|
const filtered = filterProps(result.props, req.query);
|
||||||
|
const props = groupProps(filtered);
|
||||||
|
|
||||||
|
if (result.stale) {
|
||||||
|
res.set('X-BetonBLK-Stale', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
sport: 'ncaab',
|
||||||
|
updated_at: result.updated_at,
|
||||||
|
source: result.source,
|
||||||
|
quota_remaining: result.quota_remaining,
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.statusCode || 500;
|
||||||
|
return res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const app = require('./app');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[BetonBLK] Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const { getRedisClient } = require('../utils/redis');
|
||||||
|
const { normalizeProps, MARKET_MAP } = require('../utils/oddsNormalizer');
|
||||||
|
|
||||||
|
const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports';
|
||||||
|
const CACHE_TTL = 900; // 15 minutes in seconds
|
||||||
|
const SPORT_KEYS = { nba: 'basketball_nba', ncaab: 'basketball_ncaab' };
|
||||||
|
const ALL_MARKETS = Object.keys(MARKET_MAP).join(',');
|
||||||
|
const BOOKMAKERS = 'draftkings,fanduel,betmgm';
|
||||||
|
|
||||||
|
function getCacheKey(sport) {
|
||||||
|
const now = new Date();
|
||||||
|
const date = now.toISOString().split('T')[0]; // UTC date
|
||||||
|
return `odds:${sport}:${date}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuotaKey() {
|
||||||
|
const now = new Date();
|
||||||
|
const month = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||||
|
return `odds:quota:${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getQuotaRemaining(redis) {
|
||||||
|
const data = await redis.hgetall(getQuotaKey());
|
||||||
|
return data.remaining != null ? parseInt(data.remaining, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateQuota(redis, headers) {
|
||||||
|
const remaining = headers['x-requests-remaining'];
|
||||||
|
const used = headers['x-requests-used'];
|
||||||
|
if (remaining != null) {
|
||||||
|
const key = getQuotaKey();
|
||||||
|
await redis.hset(key, 'remaining', String(remaining), 'used', String(used || 0), 'last_checked', new Date().toISOString());
|
||||||
|
await redis.expire(key, 60 * 60 * 24 * 35); // keep for ~1 month
|
||||||
|
if (parseInt(remaining, 10) < 50) {
|
||||||
|
console.warn(`[BetonBLK] Odds API quota low: ${remaining} credits remaining`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remaining != null ? parseInt(remaining, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEventsFromApi(sportKey, apiKey) {
|
||||||
|
const url = `${ODDS_API_BASE}/${sportKey}/events`;
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
params: { apiKey },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
return { data: response.data, headers: response.headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEventOddsFromApi(sportKey, eventId, apiKey) {
|
||||||
|
const url = `${ODDS_API_BASE}/${sportKey}/events/${eventId}/odds`;
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
params: {
|
||||||
|
apiKey,
|
||||||
|
regions: 'us',
|
||||||
|
markets: ALL_MARKETS,
|
||||||
|
bookmakers: BOOKMAKERS,
|
||||||
|
oddsFormat: 'american',
|
||||||
|
},
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
return { data: response.data, headers: response.headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllOdds(sport, apiKey) {
|
||||||
|
const sportKey = SPORT_KEYS[sport];
|
||||||
|
if (!sportKey) throw new Error(`Unknown sport: ${sport}`);
|
||||||
|
|
||||||
|
// Step 1: Get today's events
|
||||||
|
const eventsResult = await fetchEventsFromApi(sportKey, apiKey);
|
||||||
|
const events = eventsResult.data;
|
||||||
|
let lastHeaders = eventsResult.headers;
|
||||||
|
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
return { props: [], quotaRemaining: await parseQuota(lastHeaders) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Fetch odds for each event
|
||||||
|
const eventsWithOdds = [];
|
||||||
|
for (const event of events) {
|
||||||
|
const quotaLeft = lastHeaders['x-requests-remaining'];
|
||||||
|
if (quotaLeft != null && parseInt(quotaLeft, 10) <= 0) {
|
||||||
|
console.warn('[BetonBLK] Quota exhausted mid-fetch, stopping');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oddsResult = await fetchEventOddsFromApi(sportKey, event.id, apiKey);
|
||||||
|
eventsWithOdds.push(oddsResult.data);
|
||||||
|
lastHeaders = oddsResult.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = normalizeProps(eventsWithOdds);
|
||||||
|
const quotaRemaining = lastHeaders['x-requests-remaining']
|
||||||
|
? parseInt(lastHeaders['x-requests-remaining'], 10)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { props, quotaRemaining, headers: lastHeaders };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuota(headers) {
|
||||||
|
const val = headers && headers['x-requests-remaining'];
|
||||||
|
return val != null ? parseInt(val, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOdds(sport) {
|
||||||
|
const redis = getRedisClient();
|
||||||
|
const apiKey = process.env.ODDS_API_KEY;
|
||||||
|
const cacheKey = getCacheKey(sport);
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = await redis.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
const data = JSON.parse(cached);
|
||||||
|
const quota = await getQuotaRemaining(redis);
|
||||||
|
return {
|
||||||
|
sport,
|
||||||
|
updated_at: data.updated_at,
|
||||||
|
source: 'cache',
|
||||||
|
quota_remaining: quota,
|
||||||
|
props: data.props,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check quota before making API call
|
||||||
|
const currentQuota = await getQuotaRemaining(redis);
|
||||||
|
if (currentQuota != null && currentQuota <= 0) {
|
||||||
|
const error = new Error('Odds data temporarily unavailable. Try again later.');
|
||||||
|
error.statusCode = 429;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch live data
|
||||||
|
try {
|
||||||
|
const { props, quotaRemaining, headers } = await fetchAllOdds(sport, apiKey);
|
||||||
|
|
||||||
|
// Update quota in Redis
|
||||||
|
if (headers) {
|
||||||
|
await updateQuota(redis, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const cacheData = { updated_at: now, props };
|
||||||
|
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sport,
|
||||||
|
updated_at: now,
|
||||||
|
source: 'live',
|
||||||
|
quota_remaining: quotaRemaining,
|
||||||
|
props,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// If API fails, try stale cache (no TTL check — any cached data)
|
||||||
|
const stale = await redis.get(cacheKey);
|
||||||
|
if (stale) {
|
||||||
|
const data = JSON.parse(stale);
|
||||||
|
const quota = await getQuotaRemaining(redis);
|
||||||
|
return {
|
||||||
|
sport,
|
||||||
|
updated_at: data.updated_at,
|
||||||
|
source: 'cache',
|
||||||
|
stale: true,
|
||||||
|
quota_remaining: quota,
|
||||||
|
props: data.props,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache at all
|
||||||
|
if (err.statusCode === 429) throw err;
|
||||||
|
const serviceError = new Error('Odds service unavailable.');
|
||||||
|
serviceError.statusCode = 503;
|
||||||
|
throw serviceError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOdds,
|
||||||
|
fetchAllOdds,
|
||||||
|
fetchEventsFromApi,
|
||||||
|
fetchEventOddsFromApi,
|
||||||
|
getCacheKey,
|
||||||
|
getQuotaKey,
|
||||||
|
updateQuota,
|
||||||
|
getQuotaRemaining,
|
||||||
|
CACHE_TTL,
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const { getAbbreviation } = require('./teamMap');
|
||||||
|
|
||||||
|
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm']);
|
||||||
|
|
||||||
|
const MARKET_MAP = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeProps(eventsWithOdds) {
|
||||||
|
const props = [];
|
||||||
|
|
||||||
|
for (const event of eventsWithOdds) {
|
||||||
|
const homeTeam = getAbbreviation(event.home_team);
|
||||||
|
const awayTeam = getAbbreviation(event.away_team);
|
||||||
|
const gameTime = event.commence_time;
|
||||||
|
|
||||||
|
if (!Array.isArray(event.bookmakers)) continue;
|
||||||
|
|
||||||
|
for (const bookmaker of event.bookmakers) {
|
||||||
|
if (!ALLOWED_BOOKS.has(bookmaker.key)) continue;
|
||||||
|
|
||||||
|
if (!Array.isArray(bookmaker.markets)) continue;
|
||||||
|
|
||||||
|
for (const market of bookmaker.markets) {
|
||||||
|
const statType = MARKET_MAP[market.key];
|
||||||
|
if (!statType) continue;
|
||||||
|
|
||||||
|
const fetchedAt = market.last_update;
|
||||||
|
const outcomes = market.outcomes || [];
|
||||||
|
|
||||||
|
// Group outcomes by player+point to pair Over/Under
|
||||||
|
const grouped = {};
|
||||||
|
for (const outcome of outcomes) {
|
||||||
|
if (!outcome.description || outcome.point == null) continue;
|
||||||
|
const key = `${outcome.description}::${outcome.point}`;
|
||||||
|
if (!grouped[key]) {
|
||||||
|
grouped[key] = { player: outcome.description, point: outcome.point };
|
||||||
|
}
|
||||||
|
if (outcome.name === 'Over') {
|
||||||
|
grouped[key].over_odds = outcome.price;
|
||||||
|
} else if (outcome.name === 'Under') {
|
||||||
|
grouped[key].under_odds = outcome.price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of Object.values(grouped)) {
|
||||||
|
// Skip if we don't have both sides
|
||||||
|
if (entry.over_odds == null && entry.under_odds == null) continue;
|
||||||
|
|
||||||
|
// Player-to-team resolution deferred to Feature 1.2 (roster data)
|
||||||
|
props.push({
|
||||||
|
player: entry.player,
|
||||||
|
home_team: homeTeam,
|
||||||
|
away_team: awayTeam,
|
||||||
|
game_time: gameTime,
|
||||||
|
stat_type: statType,
|
||||||
|
book: bookmaker.key,
|
||||||
|
line: entry.point,
|
||||||
|
over_odds: entry.over_odds ?? null,
|
||||||
|
under_odds: entry.under_odds ?? null,
|
||||||
|
fetched_at: fetchedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizeProps, MARKET_MAP, ALLOWED_BOOKS };
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const Redis = require('ioredis');
|
||||||
|
|
||||||
|
let client = null;
|
||||||
|
|
||||||
|
function getRedisClient() {
|
||||||
|
if (!client) {
|
||||||
|
client = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379');
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getRedisClient };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const TEAM_ABBREVIATIONS = {
|
||||||
|
'Atlanta Hawks': 'ATL',
|
||||||
|
'Boston Celtics': 'BOS',
|
||||||
|
'Brooklyn Nets': 'BKN',
|
||||||
|
'Charlotte Hornets': 'CHA',
|
||||||
|
'Chicago Bulls': 'CHI',
|
||||||
|
'Cleveland Cavaliers': 'CLE',
|
||||||
|
'Dallas Mavericks': 'DAL',
|
||||||
|
'Denver Nuggets': 'DEN',
|
||||||
|
'Detroit Pistons': 'DET',
|
||||||
|
'Golden State Warriors': 'GSW',
|
||||||
|
'Houston Rockets': 'HOU',
|
||||||
|
'Indiana Pacers': 'IND',
|
||||||
|
'Los Angeles Clippers': 'LAC',
|
||||||
|
'Los Angeles Lakers': 'LAL',
|
||||||
|
'Memphis Grizzlies': 'MEM',
|
||||||
|
'Miami Heat': 'MIA',
|
||||||
|
'Milwaukee Bucks': 'MIL',
|
||||||
|
'Minnesota Timberwolves': 'MIN',
|
||||||
|
'New Orleans Pelicans': 'NOP',
|
||||||
|
'New York Knicks': 'NYK',
|
||||||
|
'Oklahoma City Thunder': 'OKC',
|
||||||
|
'Orlando Magic': 'ORL',
|
||||||
|
'Philadelphia 76ers': 'PHI',
|
||||||
|
'Phoenix Suns': 'PHX',
|
||||||
|
'Portland Trail Blazers': 'POR',
|
||||||
|
'Sacramento Kings': 'SAC',
|
||||||
|
'San Antonio Spurs': 'SAS',
|
||||||
|
'Toronto Raptors': 'TOR',
|
||||||
|
'Utah Jazz': 'UTA',
|
||||||
|
'Washington Wizards': 'WAS',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getAbbreviation(fullName) {
|
||||||
|
return TEAM_ABBREVIATIONS[fullName] || fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { TEAM_ABBREVIATIONS, getAbbreviation };
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
// Mock Redis before importing app
|
||||||
|
const mockRedis = {
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
hset: jest.fn(),
|
||||||
|
hgetall: jest.fn(),
|
||||||
|
expire: jest.fn(),
|
||||||
|
};
|
||||||
|
jest.mock('../../src/utils/redis', () => ({
|
||||||
|
getRedisClient: () => mockRedis,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock axios
|
||||||
|
jest.mock('axios');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// Set env
|
||||||
|
process.env.ODDS_API_KEY = 'test-key';
|
||||||
|
|
||||||
|
const app = require('../../src/app');
|
||||||
|
|
||||||
|
const MOCK_EVENTS = [
|
||||||
|
{
|
||||||
|
id: 'game-1',
|
||||||
|
sport_key: 'basketball_nba',
|
||||||
|
home_team: 'Denver Nuggets',
|
||||||
|
away_team: 'Los Angeles Lakers',
|
||||||
|
commence_time: '2026-03-21T19:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_ODDS_RESPONSE = {
|
||||||
|
...MOCK_EVENTS[0],
|
||||||
|
bookmakers: [
|
||||||
|
{
|
||||||
|
key: 'draftkings',
|
||||||
|
title: 'DraftKings',
|
||||||
|
markets: [
|
||||||
|
{
|
||||||
|
key: 'player_points',
|
||||||
|
last_update: '2026-03-21T14:28:00Z',
|
||||||
|
outcomes: [
|
||||||
|
{ name: 'Over', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
||||||
|
{ name: 'Under', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
||||||
|
{ name: 'Over', description: 'LeBron James', price: -115, point: 25.5 },
|
||||||
|
{ name: 'Under', description: 'LeBron James', price: -105, point: 25.5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'player_rebounds',
|
||||||
|
last_update: '2026-03-21T14:28:00Z',
|
||||||
|
outcomes: [
|
||||||
|
{ name: 'Over', description: 'Nikola Jokic', price: -130, point: 12.5 },
|
||||||
|
{ name: 'Under', description: 'Nikola Jokic', price: 110, point: 12.5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fanduel',
|
||||||
|
title: 'FanDuel',
|
||||||
|
markets: [
|
||||||
|
{
|
||||||
|
key: 'player_points',
|
||||||
|
last_update: '2026-03-21T14:30:00Z',
|
||||||
|
outcomes: [
|
||||||
|
{ name: 'Over', description: 'Nikola Jokic', price: -105, point: 27.0 },
|
||||||
|
{ name: 'Under', description: 'Nikola Jokic', price: -115, point: 27.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_HEADERS = {
|
||||||
|
'x-requests-remaining': '488',
|
||||||
|
'x-requests-used': '12',
|
||||||
|
'x-requests-last': '1',
|
||||||
|
};
|
||||||
|
|
||||||
|
function setupMockApi() {
|
||||||
|
axios.get
|
||||||
|
.mockResolvedValueOnce({ data: MOCK_EVENTS, headers: API_HEADERS })
|
||||||
|
.mockResolvedValueOnce({ data: MOCK_ODDS_RESPONSE, headers: API_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockResolvedValue('OK');
|
||||||
|
mockRedis.hset.mockResolvedValue(1);
|
||||||
|
mockRedis.hgetall.mockResolvedValue({});
|
||||||
|
mockRedis.expire.mockResolvedValue(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/odds/nba', () => {
|
||||||
|
it('returns full response with correct shape on live fetch', async () => {
|
||||||
|
setupMockApi();
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/odds/nba').expect(200);
|
||||||
|
|
||||||
|
expect(res.body.sport).toBe('nba');
|
||||||
|
expect(res.body.source).toBe('live');
|
||||||
|
expect(res.body.updated_at).toBeDefined();
|
||||||
|
expect(res.body.quota_remaining).toBe(488);
|
||||||
|
expect(Array.isArray(res.body.props)).toBe(true);
|
||||||
|
expect(res.body.props.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check grouped structure
|
||||||
|
const jokicPoints = res.body.props.find(
|
||||||
|
(p) => p.player === 'Nikola Jokic' && p.stat_type === 'points'
|
||||||
|
);
|
||||||
|
expect(jokicPoints).toBeDefined();
|
||||||
|
expect(jokicPoints.home_team).toBe('DEN');
|
||||||
|
expect(jokicPoints.away_team).toBe('LAL');
|
||||||
|
expect(jokicPoints.lines.length).toBe(2); // draftkings + fanduel
|
||||||
|
expect(jokicPoints.lines[0]).toHaveProperty('book');
|
||||||
|
expect(jokicPoints.lines[0]).toHaveProperty('line');
|
||||||
|
expect(jokicPoints.lines[0]).toHaveProperty('over_odds');
|
||||||
|
expect(jokicPoints.lines[0]).toHaveProperty('under_odds');
|
||||||
|
expect(jokicPoints.lines[0]).toHaveProperty('fetched_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves cached data on second request within 15 minutes', async () => {
|
||||||
|
const cachedData = {
|
||||||
|
updated_at: '2026-03-21T14:00:00Z',
|
||||||
|
props: [
|
||||||
|
{
|
||||||
|
player: 'Nikola Jokic',
|
||||||
|
home_team: 'DEN',
|
||||||
|
away_team: 'LAL',
|
||||||
|
game_time: '2026-03-21T19:00:00Z',
|
||||||
|
stat_type: 'points',
|
||||||
|
book: 'draftkings',
|
||||||
|
line: 26.5,
|
||||||
|
over_odds: -110,
|
||||||
|
under_odds: -110,
|
||||||
|
fetched_at: '2026-03-21T14:28:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockRedis.get.mockResolvedValue(JSON.stringify(cachedData));
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/odds/nba').expect(200);
|
||||||
|
|
||||||
|
expect(res.body.source).toBe('cache');
|
||||||
|
expect(axios.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by stat_type', async () => {
|
||||||
|
setupMockApi();
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/odds/nba?stat_type=rebounds').expect(200);
|
||||||
|
|
||||||
|
for (const prop of res.body.props) {
|
||||||
|
expect(prop.stat_type).toBe('rebounds');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by player (partial match, case-insensitive)', async () => {
|
||||||
|
setupMockApi();
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/odds/nba?player=jokic').expect(200);
|
||||||
|
|
||||||
|
for (const prop of res.body.props) {
|
||||||
|
expect(prop.player.toLowerCase()).toContain('jokic');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by book', async () => {
|
||||||
|
setupMockApi();
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/odds/nba?book=fanduel').expect(200);
|
||||||
|
|
||||||
|
for (const prop of res.body.props) {
|
||||||
|
for (const line of prop.lines) {
|
||||||
|
expect(line.book).toBe('fanduel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for invalid stat_type', async () => {
|
||||||
|
const res = await request(app).get('/api/odds/nba?stat_type=invalid').expect(400);
|
||||||
|
expect(res.body.error).toContain('Invalid stat_type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for invalid book', async () => {
|
||||||
|
const res = await request(app).get('/api/odds/nba?book=bovada').expect(400);
|
||||||
|
expect(res.body.error).toContain('Invalid book');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns stale cache data when API fails', async () => {
|
||||||
|
mockRedis.get
|
||||||
|
.mockResolvedValueOnce(null) // cache miss
|
||||||
|
.mockResolvedValueOnce(JSON.stringify({ // stale fallback
|
||||||
|
updated_at: '2026-03-21T12:00:00Z',
|
||||||
|
props: [{ player: 'Stale Data', home_team: 'DEN', away_team: 'LAL', game_time: '2026-03-21T19:00:00Z', stat_type: 'points', book: 'draftkings', line: 26.5, over_odds: -110, under_odds: -110, fetched_at: '2026-03-21T12:00:00Z' }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
axios.get.mockRejectedValue(new Error('API down'));
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/odds/nba').expect(200);
|
||||||
|
|
||||||
|
expect(res.body.source).toBe('cache');
|
||||||
|
expect(res.headers['x-betonblk-stale']).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 503 when API fails and no cache', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
axios.get.mockRejectedValue(new Error('API down'));
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/odds/nba').expect(503);
|
||||||
|
expect(res.body.error).toBe('Odds service unavailable.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/odds/ncaab', () => {
|
||||||
|
it('returns off-season message when NCAAB not in season', async () => {
|
||||||
|
// Mock Date to July (off-season)
|
||||||
|
const realDate = Date;
|
||||||
|
const mockDate = new Date('2026-07-15T12:00:00Z');
|
||||||
|
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||||
|
Date.now = realDate.now;
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/odds/ncaab').expect(200);
|
||||||
|
|
||||||
|
expect(res.body.props).toEqual([]);
|
||||||
|
expect(res.body.message).toContain('off-season');
|
||||||
|
expect(res.body.source).toBe('none');
|
||||||
|
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
const { normalizeProps, MARKET_MAP, ALLOWED_BOOKS } = require('../../src/utils/oddsNormalizer');
|
||||||
|
|
||||||
|
function makeEvent(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 'event-1',
|
||||||
|
sport_key: 'basketball_nba',
|
||||||
|
home_team: 'Denver Nuggets',
|
||||||
|
away_team: 'Los Angeles Lakers',
|
||||||
|
commence_time: '2026-03-21T19:00:00Z',
|
||||||
|
bookmakers: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBookmaker(key, markets) {
|
||||||
|
return { key, title: key, markets };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMarket(marketKey, outcomes, lastUpdate = '2026-03-21T14:28:00Z') {
|
||||||
|
return { key: marketKey, last_update: lastUpdate, outcomes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeOutcome(name, player, price, point) {
|
||||||
|
return { name, description: player, price, point };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('oddsNormalizer', () => {
|
||||||
|
describe('normalizeProps', () => {
|
||||||
|
it('normalizes a raw response with multiple books and markets', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
bookmakers: [
|
||||||
|
makeBookmaker('draftkings', [
|
||||||
|
makeMarket('player_points', [
|
||||||
|
makeOutcome('Over', 'Nikola Jokic', -110, 26.5),
|
||||||
|
makeOutcome('Under', 'Nikola Jokic', -110, 26.5),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
makeBookmaker('fanduel', [
|
||||||
|
makeMarket('player_points', [
|
||||||
|
makeOutcome('Over', 'Nikola Jokic', -105, 27.0),
|
||||||
|
makeOutcome('Under', 'Nikola Jokic', -115, 27.0),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = normalizeProps([event]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
player: 'Nikola Jokic',
|
||||||
|
home_team: 'DEN',
|
||||||
|
away_team: 'LAL',
|
||||||
|
stat_type: 'points',
|
||||||
|
book: 'draftkings',
|
||||||
|
line: 26.5,
|
||||||
|
over_odds: -110,
|
||||||
|
under_odds: -110,
|
||||||
|
});
|
||||||
|
expect(result[1]).toMatchObject({
|
||||||
|
player: 'Nikola Jokic',
|
||||||
|
book: 'fanduel',
|
||||||
|
line: 27.0,
|
||||||
|
over_odds: -105,
|
||||||
|
under_odds: -115,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out books not in the allowed set', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
bookmakers: [
|
||||||
|
makeBookmaker('bovada', [
|
||||||
|
makeMarket('player_points', [
|
||||||
|
makeOutcome('Over', 'Jokic', -110, 26.5),
|
||||||
|
makeOutcome('Under', 'Jokic', -110, 26.5),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
makeBookmaker('draftkings', [
|
||||||
|
makeMarket('player_points', [
|
||||||
|
makeOutcome('Over', 'Jokic', -110, 26.5),
|
||||||
|
makeOutcome('Under', 'Jokic', -110, 26.5),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = normalizeProps([event]);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].book).toBe('draftkings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps all 8 market keys to correct internal stat_types', () => {
|
||||||
|
const markets = Object.entries(MARKET_MAP);
|
||||||
|
const bookmaker = makeBookmaker(
|
||||||
|
'draftkings',
|
||||||
|
markets.map(([key]) =>
|
||||||
|
makeMarket(key, [
|
||||||
|
makeOutcome('Over', 'Test Player', -110, 10.5),
|
||||||
|
makeOutcome('Under', 'Test Player', -110, 10.5),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = makeEvent({ bookmakers: [bookmaker] });
|
||||||
|
const result = normalizeProps([event]);
|
||||||
|
|
||||||
|
const statTypes = result.map((p) => p.stat_type);
|
||||||
|
const expected = Object.values(MARKET_MAP);
|
||||||
|
expect(statTypes).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing/null odds gracefully (skips incomplete outcomes)', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
bookmakers: [
|
||||||
|
makeBookmaker('draftkings', [
|
||||||
|
makeMarket('player_points', [
|
||||||
|
// Missing description
|
||||||
|
{ name: 'Over', description: null, price: -110, point: 26.5 },
|
||||||
|
// Missing point
|
||||||
|
{ name: 'Over', description: 'Jokic', price: -110, point: null },
|
||||||
|
// Valid pair
|
||||||
|
makeOutcome('Over', 'LeBron James', -110, 25.5),
|
||||||
|
makeOutcome('Under', 'LeBron James', -110, 25.5),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = normalizeProps([event]);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].player).toBe('LeBron James');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(normalizeProps([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for events with no bookmakers', () => {
|
||||||
|
const event = makeEvent({ bookmakers: undefined });
|
||||||
|
expect(normalizeProps([event])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an outcome with only Over (no Under pair)', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
bookmakers: [
|
||||||
|
makeBookmaker('fanduel', [
|
||||||
|
makeMarket('player_points', [
|
||||||
|
makeOutcome('Over', 'Solo Player', -110, 20.5),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = normalizeProps([event]);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].over_odds).toBe(-110);
|
||||||
|
expect(result[0].under_odds).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses UTC timestamps from the API as fetched_at', () => {
|
||||||
|
const ts = '2026-03-21T18:00:00Z';
|
||||||
|
const event = makeEvent({
|
||||||
|
bookmakers: [
|
||||||
|
makeBookmaker('betmgm', [
|
||||||
|
makeMarket('player_points', [
|
||||||
|
makeOutcome('Over', 'Player A', -110, 10.5),
|
||||||
|
makeOutcome('Under', 'Player A', -110, 10.5),
|
||||||
|
], ts),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = normalizeProps([event]);
|
||||||
|
expect(result[0].fetched_at).toBe(ts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps team names to 3-letter abbreviations', () => {
|
||||||
|
const event = makeEvent({
|
||||||
|
home_team: 'Golden State Warriors',
|
||||||
|
away_team: 'Phoenix Suns',
|
||||||
|
bookmakers: [
|
||||||
|
makeBookmaker('draftkings', [
|
||||||
|
makeMarket('player_points', [
|
||||||
|
makeOutcome('Over', 'Steph Curry', -110, 28.5),
|
||||||
|
makeOutcome('Under', 'Steph Curry', -110, 28.5),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = normalizeProps([event]);
|
||||||
|
expect(result[0].home_team).toBe('GSW');
|
||||||
|
expect(result[0].away_team).toBe('PHX');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
const { getOdds, getCacheKey, getQuotaKey, updateQuota, getQuotaRemaining, CACHE_TTL } = require('../../src/services/oddsService');
|
||||||
|
|
||||||
|
// Mock Redis
|
||||||
|
const mockRedis = {
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
hset: jest.fn(),
|
||||||
|
hgetall: jest.fn(),
|
||||||
|
expire: jest.fn(),
|
||||||
|
};
|
||||||
|
jest.mock('../../src/utils/redis', () => ({
|
||||||
|
getRedisClient: () => mockRedis,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock axios
|
||||||
|
jest.mock('axios');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// Set API key for tests
|
||||||
|
process.env.ODDS_API_KEY = 'test-api-key';
|
||||||
|
|
||||||
|
const MOCK_EVENT = {
|
||||||
|
id: 'event-1',
|
||||||
|
sport_key: 'basketball_nba',
|
||||||
|
home_team: 'Denver Nuggets',
|
||||||
|
away_team: 'Los Angeles Lakers',
|
||||||
|
commence_time: '2026-03-21T19:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_EVENT_WITH_ODDS = {
|
||||||
|
...MOCK_EVENT,
|
||||||
|
bookmakers: [
|
||||||
|
{
|
||||||
|
key: 'draftkings',
|
||||||
|
title: 'DraftKings',
|
||||||
|
markets: [
|
||||||
|
{
|
||||||
|
key: 'player_points',
|
||||||
|
last_update: '2026-03-21T14:28:00Z',
|
||||||
|
outcomes: [
|
||||||
|
{ name: 'Over', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
||||||
|
{ name: 'Under', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockAxiosSuccess() {
|
||||||
|
axios.get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: [MOCK_EVENT],
|
||||||
|
headers: { 'x-requests-remaining': '490', 'x-requests-used': '10' },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: MOCK_EVENT_WITH_ODDS,
|
||||||
|
headers: { 'x-requests-remaining': '489', 'x-requests-used': '11' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockResolvedValue('OK');
|
||||||
|
mockRedis.hset.mockResolvedValue(1);
|
||||||
|
mockRedis.hgetall.mockResolvedValue({});
|
||||||
|
mockRedis.expire.mockResolvedValue(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('oddsService', () => {
|
||||||
|
describe('getOdds - cache hit', () => {
|
||||||
|
it('returns cached data when cache is fresh (no API call made)', async () => {
|
||||||
|
const cachedData = {
|
||||||
|
updated_at: '2026-03-21T14:00:00Z',
|
||||||
|
props: [{ player: 'Jokic', stat_type: 'points' }],
|
||||||
|
};
|
||||||
|
mockRedis.get.mockResolvedValue(JSON.stringify(cachedData));
|
||||||
|
mockRedis.hgetall.mockResolvedValue({ remaining: '450' });
|
||||||
|
|
||||||
|
const result = await getOdds('nba');
|
||||||
|
|
||||||
|
expect(result.source).toBe('cache');
|
||||||
|
expect(result.props).toEqual(cachedData.props);
|
||||||
|
expect(result.quota_remaining).toBe(450);
|
||||||
|
expect(axios.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOdds - cache miss', () => {
|
||||||
|
it('calls Odds API when cache is empty', async () => {
|
||||||
|
mockAxiosSuccess();
|
||||||
|
|
||||||
|
const result = await getOdds('nba');
|
||||||
|
|
||||||
|
expect(result.source).toBe('live');
|
||||||
|
expect(result.props.length).toBeGreaterThan(0);
|
||||||
|
expect(result.props[0].player).toBe('Nikola Jokic');
|
||||||
|
expect(axios.get).toHaveBeenCalledTimes(2); // events + 1 event odds
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores normalized data in Redis after successful fetch', async () => {
|
||||||
|
mockAxiosSuccess();
|
||||||
|
|
||||||
|
await getOdds('nba');
|
||||||
|
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/^odds:nba:/),
|
||||||
|
expect.any(String),
|
||||||
|
'EX',
|
||||||
|
CACHE_TTL
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOdds - API failure', () => {
|
||||||
|
it('returns stale cache on API failure', async () => {
|
||||||
|
// First call: no cache -> will try API
|
||||||
|
mockRedis.get
|
||||||
|
.mockResolvedValueOnce(null) // initial cache check
|
||||||
|
.mockResolvedValueOnce(JSON.stringify({ // stale cache fallback
|
||||||
|
updated_at: '2026-03-21T12:00:00Z',
|
||||||
|
props: [{ player: 'Stale Jokic' }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
axios.get.mockRejectedValue(new Error('API timeout'));
|
||||||
|
|
||||||
|
const result = await getOdds('nba');
|
||||||
|
|
||||||
|
expect(result.source).toBe('cache');
|
||||||
|
expect(result.stale).toBe(true);
|
||||||
|
expect(result.props[0].player).toBe('Stale Jokic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 503 when API fails and no cache exists', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
axios.get.mockRejectedValue(new Error('API down'));
|
||||||
|
|
||||||
|
await expect(getOdds('nba')).rejects.toMatchObject({
|
||||||
|
message: 'Odds service unavailable.',
|
||||||
|
statusCode: 503,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOdds - quota management', () => {
|
||||||
|
it('tracks quota from response headers', async () => {
|
||||||
|
mockAxiosSuccess();
|
||||||
|
|
||||||
|
await getOdds('nba');
|
||||||
|
|
||||||
|
expect(mockRedis.hset).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/^odds:quota:/),
|
||||||
|
'remaining', '489',
|
||||||
|
'used', '11',
|
||||||
|
'last_checked', expect.any(String)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks fetches when quota is 0', async () => {
|
||||||
|
mockRedis.hgetall.mockResolvedValue({ remaining: '0' });
|
||||||
|
|
||||||
|
await expect(getOdds('nba')).rejects.toMatchObject({
|
||||||
|
message: 'Odds data temporarily unavailable. Try again later.',
|
||||||
|
statusCode: 429,
|
||||||
|
});
|
||||||
|
expect(axios.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('utility functions', () => {
|
||||||
|
it('getCacheKey uses UTC date', () => {
|
||||||
|
const key = getCacheKey('nba');
|
||||||
|
expect(key).toMatch(/^odds:nba:\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getQuotaKey uses UTC year-month', () => {
|
||||||
|
const key = getQuotaKey();
|
||||||
|
expect(key).toMatch(/^odds:quota:\d{4}-\d{2}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user