Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
.git
|
||||
.github
|
||||
.next
|
||||
**/.next
|
||||
coverage
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
tests
|
||||
**/__tests__
|
||||
web/.next
|
||||
web/node_modules
|
||||
web/out
|
||||
data/training
|
||||
docs/
|
||||
*.md
|
||||
!CLAUDE.md
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Dependency Security Scan
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * 1' # Weekly Monday noon UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
python-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pip install pip-audit
|
||||
- run: pip-audit -r src/services/python/requirements.txt
|
||||
|
||||
node-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm audit --production
|
||||
@@ -0,0 +1,32 @@
|
||||
name: VYNDR Morning Odds Scan
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 15 * * *' # 10am ET
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
morning-odds:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/services/python
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Fetch morning odds
|
||||
env:
|
||||
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
|
||||
ODDS_API_KEY: ${{ secrets.ODDS_API_KEY }}
|
||||
SHADOW_MODE: ${{ vars.SHADOW_MODE }}
|
||||
run: |
|
||||
python -c "import sys; sys.path.insert(0, '.'); from blueprints.odds_scanner import fetch_and_store_odds; fetch_and_store_odds('nba', 'morning_open'); fetch_and_store_odds('mlb', 'morning_open')"
|
||||
@@ -0,0 +1,32 @@
|
||||
name: VYNDR Nightly Resolution
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 7 * * *' # 2am ET
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
nightly-resolution:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/services/python
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Run nightly resolution
|
||||
env:
|
||||
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
|
||||
ODDS_API_KEY: ${{ secrets.ODDS_API_KEY }}
|
||||
SHADOW_MODE: ${{ vars.SHADOW_MODE }}
|
||||
run: |
|
||||
python -c "import sys; sys.path.insert(0, '.'); from blueprints.resolution import nightly_resolution_job; from datetime import date, timedelta; nightly_resolution_job((date.today() - timedelta(days=1)).isoformat())"
|
||||
@@ -0,0 +1,50 @@
|
||||
name: VYNDR Pre-Game Scans
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 20 * * *' # 3pm ET
|
||||
- cron: '0 22 * * *' # 5pm ET
|
||||
- cron: '30 23 * * *' # 6:30pm ET
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
pregame-scan:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/services/python
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Fetch pregame odds
|
||||
env:
|
||||
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
|
||||
ODDS_API_KEY: ${{ secrets.ODDS_API_KEY }}
|
||||
SHADOW_MODE: ${{ vars.SHADOW_MODE }}
|
||||
run: |
|
||||
python -c "import sys; sys.path.insert(0, '.'); from blueprints.odds_scanner import fetch_and_store_odds; fetch_and_store_odds('nba', 'pregame'); fetch_and_store_odds('mlb', 'pregame')"
|
||||
|
||||
- name: Detect line movement
|
||||
env:
|
||||
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
|
||||
SHADOW_MODE: ${{ vars.SHADOW_MODE }}
|
||||
run: |
|
||||
python -c "import sys; sys.path.insert(0, '.'); from blueprints.line_movement import detect_line_movement; detect_line_movement('nba'); detect_line_movement('mlb')"
|
||||
|
||||
- name: Check lineups
|
||||
env:
|
||||
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
|
||||
SHADOW_MODE: ${{ vars.SHADOW_MODE }}
|
||||
run: |
|
||||
python -c "import sys; sys.path.insert(0, '.'); from blueprints.lineup_check import check_lineups; check_lineups('nba'); check_lineups('mlb')"
|
||||
@@ -0,0 +1,31 @@
|
||||
name: VYNDR Reporter Monitoring
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 19-5 * * *' # Every 15min during game window
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
reporter-poll:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/services/python
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Poll reporter feeds
|
||||
env:
|
||||
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
|
||||
SHADOW_MODE: ${{ vars.SHADOW_MODE }}
|
||||
run: |
|
||||
python -c "import sys; sys.path.insert(0, '.'); from blueprints.reporter_monitor import poll_reporter_feeds; poll_reporter_feeds('nba'); poll_reporter_feeds('mlb')"
|
||||
@@ -0,0 +1,31 @@
|
||||
name: VYNDR Weather Monitoring
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/30 19-4 * * *' # Every 30min during game window
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
weather-check:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/services/python
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Check weather for regrade
|
||||
env:
|
||||
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
||||
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
|
||||
SHADOW_MODE: ${{ vars.SHADOW_MODE }}
|
||||
run: |
|
||||
python -c "import sys; sys.path.insert(0, '.'); from blueprints.weather_monitor import check_weather_for_regrade; check_weather_for_regrade()"
|
||||
+12
@@ -12,6 +12,18 @@ venv/
|
||||
.pytest_cache/
|
||||
.temp/
|
||||
|
||||
# Claude Code local settings (may contain permission-allowlisted API keys)
|
||||
.claude/
|
||||
|
||||
# Security — keys and certificates
|
||||
*.pem
|
||||
*.key
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Vercel
|
||||
.vercel/
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
# ARCHITECTURE.md — VYNDR v1.0
|
||||
|
||||
---
|
||||
|
||||
## Section 1: The Mission
|
||||
|
||||
VYNDR is a bet on advancement. Sports betting intelligence SaaS built to reverse the flow of a multi-trillion dollar industry.
|
||||
|
||||
Three tiers:
|
||||
- **Free** — 5 scans
|
||||
- **Analyst** — $19.99/mo ($14.99 founder price)
|
||||
- **Desk** — $49.99/mo ($34.99 founder price)
|
||||
|
||||
The house has data scientists. We give everyone else the same firepower.
|
||||
|
||||
---
|
||||
|
||||
## Section 2: What Was Already Built (Phase 0)
|
||||
|
||||
11 features shipped. 237 tests passing.
|
||||
|
||||
### Phase 1 — Core Engine
|
||||
- **Odds API integration** — live odds ingestion from The Odds API
|
||||
- **NBA stats wrapper** — Python FastAPI microservice wrapping nba_api
|
||||
- **Prop analysis engine** — 6-step grading pipeline (season avg, recent form, situational, line comparison, kill conditions, grade)
|
||||
- **Database schema** — 9 tables with row-level security
|
||||
- **Bet submission** — 3 methods (manual, scan-to-save, quick pick)
|
||||
|
||||
### Phase 2 — Intelligence Layer
|
||||
- **Parlay scan** — correlation detection across 5 types, monetization hooks
|
||||
- **Line movement + cascade detection** — baseline capture, movement alerts, scratch-and-regrade
|
||||
|
||||
### Phase 3 — User-Facing Product
|
||||
- **Landing page** — Next.js with Tailwind, cyan design system
|
||||
- **Scan UI** — mobile-first prop scanning interface
|
||||
- **Bet tracker** — portfolio-style bet management
|
||||
- **Stripe integration** — checkout, webhooks, portal, founder code system
|
||||
|
||||
### Stack
|
||||
- **Backend:** Node.js / Express
|
||||
- **Database:** Supabase (PostgreSQL) with RLS on all tables
|
||||
- **Frontend:** Next.js, Tailwind CSS, cyan design system
|
||||
- **Caching:** Redis — 15min odds, 24hr season averages, 1hr recent games
|
||||
- **Payments:** Stripe
|
||||
- **Data:** The Odds API ($30/mo), nba_api (free, Python wrapper)
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Phase 1 Additions (Current Session)
|
||||
|
||||
12 additions built in a single session:
|
||||
|
||||
| # | Addition | What It Does |
|
||||
|---|----------|-------------|
|
||||
| 1 | Founder's Note | Immutable constant, tested for integrity |
|
||||
| 2 | Mission Header | X-VYNDR-Mission on all API responses |
|
||||
| 3 | Stats Endpoints | /api/stats/parlays-graded, /api/stats/public, /api/props/live |
|
||||
| 4 | Dynamic Role Profile System | 8 functional roles, Shannon entropy variance, conditional profiles, lineup profiles |
|
||||
| 5 | Player Selector | 4-step mobile-first flow (placeholder — frontend handled by Cowork) |
|
||||
| 6 | Parlay Probability | Per-leg probability, combined, phi coefficient correlation, juice-adjusted EV |
|
||||
| 7 | MLB Prop Grading | 14 stat types, 10 kill conditions, 30 park coordinates, weather API |
|
||||
| 8 | Intelligence Engine | Similarity, evolution/PELT, line discrepancy, alt line scanner, Bayesian, model trainer |
|
||||
| 9 | Lineup Watch Speed | Role activation detection, 60-second alert window |
|
||||
| 10 | Design System Update | Forest green variables, DemoScan result card, Hero tagline, live props strip |
|
||||
| 11 | Accuracy Ledger + Marketplace | /ledger page and /marketplace page |
|
||||
| 12 | Architecture Documentation | This file |
|
||||
|
||||
---
|
||||
|
||||
## Section 4: All Services and Data Flow
|
||||
|
||||
### Backend Services
|
||||
|
||||
#### Odds + Data Ingestion
|
||||
- **oddsService.js** — fetches from Odds API, caches in Redis (15min TTL)
|
||||
- **nbaStatsClient.js** — Python FastAPI wrapper for NBA season/game stats
|
||||
- **mlbStatsClient.js** — MLB Stats API wrapper
|
||||
|
||||
#### Grading Pipeline
|
||||
- **propAnalyzer.js** — 6-step grading pipeline (season avg, recent form, situational, line comparison, kill conditions, grade)
|
||||
- **grader.js** — composite score to grade (A/B/C/D) with confidence
|
||||
- **killConditions.js** — 6 NBA kill conditions
|
||||
- **mlbGrader.js** — MLB-specific grading with adjusted thresholds
|
||||
- **mlbKillConditions.js** — 10 MLB kill conditions + weather API integration
|
||||
|
||||
#### Parlay + Correlation
|
||||
- **correlationEngine.js** — parlay correlation detection (5 correlation types)
|
||||
- **parlayGrader.js** — parlay-level grading derived from leg grades
|
||||
- **parlayScanService.js** — orchestrates full scan flow
|
||||
- **correlationMath.js** — phi coefficient calculation, EV computation
|
||||
|
||||
#### Line Intelligence
|
||||
- **lineMovementService.js** — baseline capture + movement detection
|
||||
- **cascadeService.js** — scratch detection + regrade triggers
|
||||
- **alertService.js** — cascade alert management
|
||||
- **lineDiscrepancyDetector.js** — sharp vs square book gap detection
|
||||
- **altLineScanner.js** — optimal alternate line finding
|
||||
|
||||
#### Role + Profile System
|
||||
- **roleProfileEngine.js** — role distribution calculation, elevation detection
|
||||
- **roleStabilityEngine.js** — stability scoring with decay
|
||||
|
||||
#### Advanced Intelligence
|
||||
- **similarityEngine.js** — 10-factor game similarity matching
|
||||
- **evolutionEngine.js** — Node wrapper for Python PELT microservice
|
||||
- **bayesianEngine.js** — correct distribution shape per stat type
|
||||
- **modelTrainer.js** — walk-forward validation, CLV tracking, drift detection
|
||||
|
||||
#### Payments
|
||||
- **stripeService.js** — checkout sessions, webhooks, customer portal, founder codes
|
||||
|
||||
### Python Microservices
|
||||
|
||||
| Service | Framework | Port | Purpose |
|
||||
|---------|-----------|------|---------|
|
||||
| NBA stats wrapper | FastAPI | 8000 | nba_api Python wrapper |
|
||||
| Evolution engine | Flask | 5001 | PELT changepoint detection via ruptures |
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Odds API
|
||||
|
|
||||
v
|
||||
oddsService.js --> Redis Cache (15min TTL)
|
||||
|
|
||||
v
|
||||
propAnalyzer.js
|
||||
|-- nbaStatsClient.js --> FastAPI (port 8000) --> nba_api
|
||||
|-- mlbStatsClient.js --> MLB Stats API
|
||||
|-- roleProfileEngine.js
|
||||
|-- similarityEngine.js
|
||||
|-- bayesianEngine.js
|
||||
|
|
||||
v
|
||||
grader.js / mlbGrader.js
|
||||
|-- killConditions.js / mlbKillConditions.js
|
||||
|
|
||||
v
|
||||
parlayScanService.js
|
||||
|-- correlationEngine.js
|
||||
|-- parlayGrader.js
|
||||
|-- correlationMath.js (phi, EV)
|
||||
|
|
||||
v
|
||||
Supabase (PostgreSQL)
|
||||
|
|
||||
v
|
||||
lineMovementService.js --> cascadeService.js --> alertService.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section 5: Database Schema
|
||||
|
||||
### Original Tables (Migrations 001, 002)
|
||||
- **users** — auth, tier, founder status
|
||||
- **picks** — individual prop picks with grades
|
||||
- **scan_sessions** — scan history per user
|
||||
- **cascade_alerts** — line movement alert records
|
||||
- **line_baselines** — captured opening lines
|
||||
- **line_movements** — detected line changes
|
||||
- Plus standard auth tables (managed by Supabase)
|
||||
|
||||
### New Tables (Migration 003)
|
||||
- **player_role_profiles** — per-player role distribution snapshots
|
||||
- **lineup_role_profiles** — team lineup composition profiles
|
||||
- **player_role_activations** — role elevation/change events
|
||||
- **model_predictions_extended** — predictions with confidence and methodology
|
||||
- **prediction_registry** — pre-registered predictions (public read, service write)
|
||||
- **joint_outcomes** — correlated outcome tracking for phi coefficient
|
||||
- **discrepancy_reliability_scores** — sharp/square gap reliability history
|
||||
|
||||
All tables enforce row-level security (RLS). The prediction_registry table has public read access and service-role-only write access.
|
||||
|
||||
---
|
||||
|
||||
## Section 6: Phase 2 Pending
|
||||
|
||||
- **Model learning loop** — Feature 4.1 spec exists in specs/
|
||||
- **Player selector UI completion** — Cowork handles the design implementation
|
||||
- **Full parlay probability UI integration** — connect backend math to scan results
|
||||
- **Real-time lineup watch CRON** — scheduled job for role activation monitoring
|
||||
- **Evolution watch UI on ledger page** — visualize PELT changepoints
|
||||
- **Pre-registered predictions system activation** — public ledger goes live
|
||||
- **Physical ledger fulfillment** — printed accuracy reports for Desk tier
|
||||
- **Education library content** — betting strategy guides
|
||||
|
||||
---
|
||||
|
||||
## Section 7: Known Gaps and Next Actions
|
||||
|
||||
### Active Blockers
|
||||
- **BLOCKER-003:** WSL2 DNS cannot resolve *.supabase.co — migration 003 must be applied manually via Supabase dashboard or a machine with direct DNS
|
||||
|
||||
### Environment Dependencies
|
||||
- Python evolution microservice requires ruptures + numpy installed
|
||||
- Seed script requires NBA API network access to populate role profiles
|
||||
- Stripe environment variables (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET) must be set
|
||||
- Vercel deployment pending for Next.js frontend
|
||||
|
||||
### Next Actions
|
||||
1. Apply migration 003 manually (blocked by DNS)
|
||||
2. Install Python dependencies for evolution microservice
|
||||
3. Set Stripe env vars and test payment flow end-to-end
|
||||
4. Deploy frontend to Vercel
|
||||
5. Begin Feature 4.1 (model learning loop)
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# BetonBLK — Blockers
|
||||
# VYNDR — Blockers
|
||||
|
||||
## BLOCKER-001: Hosting Decision
|
||||
**Status:** OPEN
|
||||
|
||||
+333
-16
@@ -1,12 +1,128 @@
|
||||
# BetonBLK — Build State
|
||||
# VYNDR — Build State
|
||||
|
||||
## Last Updated
|
||||
2026-03-22
|
||||
2026-05-18
|
||||
|
||||
## Current Phase
|
||||
Phase 3 — Web MVP (COMPLETE)
|
||||
SHIP BUILD v6.0 — Web Tier Complete (Dashboard + Game Pages + Intelligence Feed + PWA + NexaPay)
|
||||
|
||||
## What Has Shipped
|
||||
## Web Tier v6 (2026-05-18) — SHIPPED
|
||||
Complete frontend overhaul. 18 pages, 22 API routes. `npm run build` passes with zero errors.
|
||||
|
||||
### New pages
|
||||
- `/dashboard` — post-login slate (sport tabs, top grades, tonight's games, most parlayed, recent scans, first-time onboarding)
|
||||
- `/game/[id]` — game preview with spread/total/ML, starting lineups with injury flags, expandable prop list, add-to-parlay
|
||||
- `/profile` — tier status, subscription state, founder badge, cancel-at-period-end flow
|
||||
- `/intelligence` — Desk-tier timeline of evolution/coaching/cascade/ABS/line-movement signals (blurred for non-Desk)
|
||||
- `/terms`, `/privacy`, `/responsible-gambling` — branded legal pages with brand voice
|
||||
- `/scan` — full rebuild (sport tabs, real /api/scan with tier gating, parlay tray hook)
|
||||
- `/login`, `/signup` — wired to Supabase Auth via AuthContext (Google OAuth + email/password + age check)
|
||||
- `/marketplace` — coming-soon waitlist (API access, custom alerts, capsule drop)
|
||||
- `/ledger`, `/tracker` — design system refresh, accuracy buckets, miss autopsy, quick-slip
|
||||
- `/` — auth-aware: logged-in users redirect to `/dashboard`; anonymous see marketing
|
||||
|
||||
### New API routes
|
||||
- `/api/games/tonight`, `/api/games/[id]`, `/api/games/[id]/props`
|
||||
- `/api/props/top-graded`, `/api/props/most-parlayed`
|
||||
- `/api/players/search`
|
||||
- `/api/user/recent-scans`
|
||||
- `/api/intelligence/feed`
|
||||
- `/api/parlay/add-leg`, `/api/parlay/grade`
|
||||
- `/api/ledger`, `/api/ledger/accuracy`
|
||||
- All cached via Supabase `odds_cache` table (5-min TTL) — never hit Odds API directly
|
||||
|
||||
### Services + middleware
|
||||
- `services/odds-cache.ts` — Supabase-backed TTL cache for upstream calls (loader + stale-fallback)
|
||||
- `services/email.ts` — Resend wrapper: `sendWelcomeEmail`, `sendPaymentReceipt`, `sendRenewalReminder`
|
||||
- `middleware/rateLimit.ts` — per-tier per-minute scan throttle (5/30/60 free/analyst/desk)
|
||||
- `services/nexapay.ts` — already shipped (createPaymentLink + HMAC webhook verify), now wired to email receipts
|
||||
|
||||
### Components
|
||||
- `GradeCard.tsx` — premium grade card with tier-gated blur (factors locked for free; alt-lines locked for non-Desk)
|
||||
- `ParlayContext.tsx` + `ParlayTray.tsx` — cross-page parlay state, slide-up tray, /api/parlay/grade integration
|
||||
- `BottomTabBar.tsx` — mobile-only 5-tab navigation (Home/Scan/Parlay/Ledger/Profile) with parlay badge
|
||||
- `ShareCard.tsx` — canvas-rendered 1200x630 OG share image with grade letter; download + copy-to-clipboard
|
||||
- `Nav.tsx`, `Hero.tsx`, `LivePropsStrip.tsx`, `Features.tsx`, `Pricing.tsx`, `HowItWorks.tsx`, `FAQ.tsx`, `Footer.tsx` — design system refresh already shipped
|
||||
|
||||
### PWA + meta
|
||||
- `public/manifest.json` (192/512/maskable icons)
|
||||
- `public/icons/icon-{192,512,maskable-512}.png`, `apple-touch-icon.png`, `favicon.ico`, `favicon.png`
|
||||
- `public/og-image.png` — 1200x630 social share card
|
||||
- `appleWebApp` + `manifest` + theme-color wired in `layout.tsx`
|
||||
|
||||
### Supabase migrations
|
||||
- `011_user_profiles_web.sql` (already deployed): `user_profiles` (+RLS+trigger), `parlay_leg_frequency` (+RPC), `scan_history`
|
||||
- `012_web_caching_waitlist.sql` (NEW): `odds_cache` (TTL cache), `waitlist_signups`, `founder_pricing_seats` view, `prune_expired_odds_cache()` helper
|
||||
|
||||
### Backend
|
||||
- `src/app.js` — CORS middleware added (localhost dev + vyndr.app + *.vercel.app + FRONTEND_ORIGINS env var)
|
||||
- `package.json` — added `cors@2.8.5`
|
||||
|
||||
### Bug fixes
|
||||
- Scan page sibling-div JSX bug fixed (rewritten from scratch)
|
||||
- Lockfile warning silenced via `next.config.ts` `turbopack.root` (already in place)
|
||||
- Auth callback rewritten to use Supabase JS session API instead of raw localStorage parse
|
||||
|
||||
## Environment variables (set in Vercel + Railway)
|
||||
### Vercel (Next.js)
|
||||
- `NEXT_PUBLIC_SUPABASE_URL` — Supabase project URL
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY` — Supabase anon key
|
||||
- `SUPABASE_SERVICE_ROLE_KEY` — service role (server-only, NEVER expose to client)
|
||||
- `NEXT_PUBLIC_SITE_URL` — `https://vyndr.app`
|
||||
- `BACKEND_URL` — Railway URL of Express grading engine
|
||||
- `NEXT_PUBLIC_API_URL` — same as BACKEND_URL (for legacy client fetches)
|
||||
- `NEXT_PUBLIC_NBA_SERVICE_URL` — FastAPI nba_api wrapper URL
|
||||
- `NEXAPAY_API_KEY` — bearer token from NexaPay dashboard
|
||||
- `NEXAPAY_WEBHOOK_SECRET` — HMAC secret from NexaPay dashboard
|
||||
- `NEXAPAY_API_URL` — defaults to `https://api.nexapay.one/v1`
|
||||
- `RESEND_API_KEY` — from resend.com
|
||||
- `RESEND_FROM_EMAIL` — defaults to `VYNDR <grades@vyndr.app>`
|
||||
- `NEXT_PUBLIC_POSTHOG_KEY` — PostHog project key (optional)
|
||||
- `NEXT_PUBLIC_POSTHOG_HOST` — defaults to `https://us.i.posthog.com`
|
||||
|
||||
### Railway (Express backend)
|
||||
- All existing engine vars (Odds API key, Supabase, etc.)
|
||||
- `FRONTEND_ORIGINS` — comma-separated additional CORS origins (optional; defaults cover localhost + vyndr.app + *.vercel.app)
|
||||
|
||||
## Vercel deployment
|
||||
1. Repo root → `/home/kev/mastermind/vyndr`
|
||||
2. Root Directory in Vercel project settings: `web`
|
||||
3. Framework Preset: Next.js (auto-detected)
|
||||
4. Build Command: `npm run build` (default)
|
||||
5. Install Command: `npm install` (default)
|
||||
6. Output Directory: `.next` (default; we use `output: 'standalone'`)
|
||||
7. Node version: 20.x or 22.x
|
||||
8. Add all env vars from the list above
|
||||
|
||||
## Railway deployment (backend)
|
||||
1. `railway.toml` already configured in repo root
|
||||
2. Connect GitHub → Deploy from `main`
|
||||
3. Set env vars (same as Vercel backend list)
|
||||
4. Get URL → set `BACKEND_URL` in Vercel
|
||||
|
||||
## NexaPay configuration
|
||||
1. Create NexaPay account → get API key + webhook secret
|
||||
2. Webhook URL: `https://vyndr.app/api/webhook/nexapay`
|
||||
3. Webhook events to enable: `payment.succeeded`, `payment.failed`, `payment.refunded`, `subscription.canceled`
|
||||
4. Settlement wallet: USDC on Polygon (or your preferred chain)
|
||||
5. Set `NEXAPAY_*` env vars in Vercel
|
||||
|
||||
## Resend configuration
|
||||
1. Create Resend account → verify `vyndr.app` domain
|
||||
2. Add DNS records (SPF, DKIM, DMARC) from Resend dashboard
|
||||
3. Create API key → set `RESEND_API_KEY` in Vercel
|
||||
4. Test: trigger a signup, check the welcome email arrives
|
||||
|
||||
## Supabase Auth setup
|
||||
1. Run migrations `011_user_profiles_web.sql` and `012_web_caching_waitlist.sql` (Supabase SQL editor or CLI)
|
||||
2. Auth → Providers → enable Email/Password (default)
|
||||
3. Auth → Providers → enable Google: paste client ID/secret from Google Cloud Console
|
||||
4. Auth → URL Configuration → Site URL: `https://vyndr.app`
|
||||
5. Auth → URL Configuration → Redirect URLs: `https://vyndr.app/auth/callback`, `http://localhost:3001/auth/callback`
|
||||
|
||||
---
|
||||
|
||||
## What Has Shipped (Backend — Already Live)
|
||||
|
||||
### Phase 1 — Foundation (COMPLETE)
|
||||
- Feature 1.1 — Odds API Integration
|
||||
@@ -20,7 +136,7 @@ Phase 3 — Web MVP (COMPLETE)
|
||||
- Feature 2.2 — Line Movement + Cascade Detection
|
||||
|
||||
### Phase 3 — Web MVP (COMPLETE)
|
||||
- Feature 3.1 — Landing Page + Blog (Next.js, MDX, BetonBLK voice, SEO)
|
||||
- Feature 3.1 — Landing Page + Blog (Next.js, MDX, VYNDR voice, SEO)
|
||||
- Feature 3.2 — Scan UI (leg builder, grade results, upgrade pitch)
|
||||
- Feature 3.3 — Bet Tracker (performance dashboard, quick slip, settle flow)
|
||||
- Feature 3.4 — Stripe Integration (checkout, webhooks, portal, founder codes)
|
||||
@@ -29,23 +145,70 @@ Phase 3 — Web MVP (COMPLETE)
|
||||
### Mastermind Agency Site
|
||||
- `/home/kev/mastermind/agency-site/`
|
||||
- Glitch aesthetic, scan lines, CRT flicker, JetBrains Mono
|
||||
- Home, BetonBLK case study, Contact pages
|
||||
- Home, VYNDR case study, Contact pages
|
||||
|
||||
### Phase 1 Additions — Intelligence Engine (COMPLETE)
|
||||
- Addition 1 — Stats endpoints (parlays-graded, public, live props)
|
||||
- Addition 2 — Dynamic role profile system (8 roles, Shannon entropy, conditional profiles)
|
||||
- Addition 3 — Player selector (placeholder — Cowork handles design)
|
||||
- Addition 4 — Parlay probability (phi coefficient, juice-adjusted EV, correlation math)
|
||||
- Addition 5 — MLB prop grading (14 stat types, 10 kill conditions, 30 parks, weather API)
|
||||
- Addition 6 — Intelligence engine (similarity, evolution/PELT, line discrepancy, alt line, Bayesian, model trainer)
|
||||
- Addition 7 — Lineup watch speed (role activation detection framework)
|
||||
- Addition 8 — Database additions (7 new tables, migration 003, indexes, RLS)
|
||||
- Addition 9 — Design system update (forest green, Hero tagline, live props strip, DemoScan result card)
|
||||
- Addition 10 — Accuracy ledger page (/ledger)
|
||||
- Addition 11 — Marketplace page (/marketplace, waitlist, honeypot)
|
||||
- Addition 12 — ARCHITECTURE.md v1.0
|
||||
- Permanent: FOUNDER_NOTE constant (immutable, tested for integrity)
|
||||
- Permanent: X-VYNDR-Mission header on all API responses
|
||||
|
||||
## Also Shipped (Separate Repo)
|
||||
### Mastermind Agency Site
|
||||
- `/home/kev/mastermind/agency-site/`
|
||||
- Glitch aesthetic, scan lines, CRT flicker, JetBrains Mono
|
||||
- Home, VYNDR case study, Contact pages
|
||||
|
||||
## Test Summary
|
||||
- Node.js: 210 tests passing (unit + integration)
|
||||
- Node.js: 662 tests passing (unit + integration) — 357 original + 187 ship + 65 supplement + 35 patch + 45 security
|
||||
- Python: 27 tests passing
|
||||
- Total: 237 tests, all green
|
||||
- Both Next.js projects build clean
|
||||
|
||||
## All Features Complete
|
||||
Every feature on the roadmap has shipped:
|
||||
- 7 backend features (Phase 1 + 2 + 1.5)
|
||||
- 4 frontend features (Phase 3)
|
||||
- 1 separate agency portfolio site
|
||||
- Total: 689 tests, all green
|
||||
- 8 new test files: shipInfrastructure, shipGradingEngine, shipDataSources, shipResolution, shipSchemeClassifier, supplementSystems, patchIntegration, securityAudit
|
||||
- Next.js project builds (pending Vercel deploy)
|
||||
|
||||
## Active Blockers
|
||||
- BLOCKER-003: WSL2 DNS cannot resolve *.supabase.co
|
||||
- Migration 002 needs manual apply via Supabase SQL Editor
|
||||
- Migrations 003-010 need manual apply via Supabase SQL Editor
|
||||
|
||||
### Phase 1 Additions Part 2 (COMPLETE)
|
||||
- Addition 13 — Simplified scan selector (sport toggle NBA/MLB, player search, stat dropdown, line pre-fill from Odds API)
|
||||
- Addition 14 — PostHog analytics integration (5 events: scan_completed, grade_viewed, upgrade_cta_clicked, prop_shared, alt_line_viewed)
|
||||
- Addition 15 — Affiliate database (Migration 004: referral_codes, referral_conversions, affiliate_payouts, wallet_addresses, RLS on all)
|
||||
- Addition 16 — Scheme intelligence data layer (schemeClassifier.js: PnR coverage classification DROP/SWITCH/HEDGE/MIXED/UNKNOWN, 8-possession min, 6hr cache, graceful degradation, silent logging to model_predictions_extended)
|
||||
- **Scheme intelligence: data layer active, user activation pending Day 31**
|
||||
|
||||
## Phase 2 Pending
|
||||
- Model learning loop (Feature 4.1 spec exists)
|
||||
- Player selector UI completion (Cowork handles design)
|
||||
- Full parlay probability UI integration
|
||||
- Real-time lineup watch CRON implementation
|
||||
- Evolution watch UI on ledger page
|
||||
- Pre-registered predictions system activation
|
||||
- Physical ledger fulfillment
|
||||
- Education library content
|
||||
|
||||
## Manual Actions Required
|
||||
1. Paste SQL migrations 003-010 in Supabase SQL Editor (in order)
|
||||
2. Run `node scripts/seedRoleProfiles.js` after NBA API access configured
|
||||
3. Set Stripe env vars (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, price IDs)
|
||||
4. Set NEXT_PUBLIC_POSTHOG_KEY env var for PostHog analytics
|
||||
5. Set ODDS_API_KEY env var for Odds API
|
||||
6. Set SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY for Python service
|
||||
7. Deploy Next.js frontend to Vercel
|
||||
8. Start Python service: `cd src/services/python && pip install -r requirements.txt && python3 app.py`
|
||||
9. Set up GitHub Actions crons: lineup monitoring (15min), morning odds (10am ET), pre-game odds (90min), weather (30min), nightly resolution (2am ET)
|
||||
10. Run cold_start_boot() on first launch (seeds reporters, loads data files)
|
||||
11. SHADOW_MODE=True for first 2 weeks — grades logged but not published to capper
|
||||
|
||||
## Session Log
|
||||
|
||||
@@ -60,3 +223,157 @@ Every feature on the roadmap has shipped:
|
||||
- Built Feature 3.4: Stripe Integration (checkout, webhooks, portal, founder codes)
|
||||
- ALL FEATURES COMPLETE
|
||||
- Total: 237 tests (210 Node.js + 27 Python), all green
|
||||
|
||||
### Session 8 — 2026-03-28
|
||||
- Built all 12 Phase 1 additions in single session
|
||||
- 68 new tests (305 total), all green
|
||||
- New services: roleProfileEngine, roleStabilityEngine, similarityEngine, evolutionEngine, lineDiscrepancyDetector, altLineScanner, bayesianEngine, modelTrainer, correlationMath, mlbGrader, mlbKillConditions, mlbStatsClient
|
||||
- New routes: stats, props, waitlist
|
||||
- New frontend: LivePropsStrip, ledger page, marketplace page
|
||||
- New constants: founderNote, mlbParks
|
||||
- New middleware: mission header
|
||||
- Migration 003: 7 new tables with indexes and RLS
|
||||
- Python microservice: evolutionEngine.py (Flask/PELT on port 5001)
|
||||
- ARCHITECTURE.md v1.0 created
|
||||
|
||||
### Session 9 — 2026-04-12
|
||||
- Built 4 Phase 1 Part 2 additions
|
||||
- 52 new tests (357 total), all green
|
||||
- New component: SimplifiedSelector (sport toggle, player search, stat dropdown, line pre-fill)
|
||||
- PostHog analytics: 5 tracked events, initialized in layout.tsx
|
||||
- Migration 004: 4 affiliate tables (referral_codes, referral_conversions, affiliate_payouts, wallet_addresses)
|
||||
- New service: schemeClassifier.js (PnR coverage classification, 6hr cache, graceful degradation)
|
||||
- Scheme intelligence: data layer active, user activation pending Day 31
|
||||
|
||||
### Session 10 — 2026-04-13 (SHIP BUILD v5.1)
|
||||
- Built complete dual-sport grading engine from vyndr-SHIP.md spec
|
||||
- 187 new tests (544 total), all green across 38 test suites
|
||||
- **Phase 1 — Infrastructure:**
|
||||
- Flask app.py with blueprints, health check, rate limiting (60/min default, 20/min grade), flask-cors, /api/docs
|
||||
- evolutionEngine.py moved to blueprints/evolution.py (structural only — logic unchanged)
|
||||
- utils: retry.py, data_warehouse.py (game-day TTL), bayesian.py (per-stat-type weights, skewness, data sufficiency curve), edge_calculator.py (real edge + quarter-Kelly), context_aggregator.py (15 factors), similarity.py (min 0.7), regime_detector.py (disabled <20 games), blind_spot_detector.py (worst 5%), supabase_client.py
|
||||
- Data files: park_factors.json (30 parks, lat/lng, roof_status), reporter_database.json (80+ handles), timezone_map.json (30 arenas), grade_thresholds.json, odds_api_config.json
|
||||
- requirements.txt with all 15 dependencies
|
||||
- Cold start boot sequence with reporter seeding
|
||||
- **Phase 2 — Data Sources:**
|
||||
- blueprints/synergy.py (team play types, matchup, tracking, defensive scheme)
|
||||
- blueprints/nba_context.py (teammate impact, game script, home/road, rest/travel, matchup pace, foul trouble, B2B stat-specific, positional defense, usage-efficiency, playoff modifiers, NBA sub-scores endpoint)
|
||||
- blueprints/lineup_intelligence.py (3-source architecture, reporter trust tiers, tweet parsing, two-stage grading, reporter-line correlation)
|
||||
- blueprints/odds_scanner.py (free tier 2 pulls/day, odds warehouse, line movement detection, slate scanner)
|
||||
- utils/weather.py (Open-Meteo, continuous 30min, dome detection, regrade triggers)
|
||||
- utils/archetypes.py (5 pitcher dimensions, 5 batter dimensions, 6 NBA dimensions — ALL with weight_profiles, batting order, batter approach, pitcher identity, weight blending)
|
||||
- schemeClassifier.js enhanced: Synergy-first with regex fallback, backward compatible
|
||||
- **Phase 3 — Grading Engines:**
|
||||
- blueprints/mlb.py (14-step pipeline, pitcher/batter profiles, ABS challenge system with player-specific discipline score, TTO decay, platoon-specific opponent quality, lineup protection, day/night, bullpen state, catcher framing)
|
||||
- blueprints/image_grade.py (OCR pipeline with low-confidence confirmation)
|
||||
- utils/sportsbooks.py (10 books, parlay grading with correlation check, phi coefficient)
|
||||
- utils/capper.py (pick numbers, breaking alerts, daily recap, miss autopsy)
|
||||
- **Phase 4 — Self-Improving Loop:**
|
||||
- blueprints/resolution.py (nightly job: actuals from nba_api/MLB-StatsAPI, hit/miss, CLV, alignment, joint outcomes, calibration triggers)
|
||||
- blueprints/calibration.py (point-biserial weights, global offset, Brier score, blind spots, CLV/alignment reports)
|
||||
- **Phase 5 — Database + Tests:**
|
||||
- Migration 005: lineup_scheme_data
|
||||
- Migration 006: nba_data_cache, mlb_data_cache, grade_outcomes (ALL ship columns incl discipline_score, CLV, alignment), player_calibrated_weights
|
||||
- Migration 007: lineup_updates, reporter_trust (with source_type + starting_trust), odds_warehouse, ship_line_movements, reporter_line_correlation, api_health_log, global_calibration, ship_joint_outcomes
|
||||
- 5 new test files covering infrastructure, grading engine, data sources, resolution pipeline, scheme classifier enhancement
|
||||
- **Key Spec Compliance:**
|
||||
- Grade thresholds LOCKED (A+ through F)
|
||||
- SHADOW_MODE = True (first 2 weeks)
|
||||
- Bayesian weights are INITIAL ESTIMATES (marked as such)
|
||||
- Abstention check BEFORE data cap
|
||||
- Point-biserial bounds 0.05-0.50, global offset ±0.15
|
||||
- Real edge with vig + quarter-Kelly
|
||||
- Brier + CLV from day one
|
||||
- Capper A- and above ONLY
|
||||
- ABS is CHALLENGE system (successful challenges don't deplete)
|
||||
- Foul trouble widens std, not mean
|
||||
- Stat-specific B2B adjustments
|
||||
- Matchup-specific pace (home 60/40)
|
||||
- Positional defense (tracking > roster position)
|
||||
- Usage-efficiency tradeoff (-1.5% TS per +5% usage)
|
||||
- Tier limits documented but NOT enforced (gate manually later)
|
||||
- Node.js stays Node.js, Python is data/utility layer via HTTP
|
||||
|
||||
### Session 10c — 2026-04-13 (FINAL INTEGRATION PATCH)
|
||||
- Applied 15-item integration patch — wiring + features + infrastructure
|
||||
- 35 new tests (644 total), all green across 40 test suites
|
||||
- **Wiring (items 1-5):**
|
||||
- Scratch → redistribution → re-grade → alt line scan → alert chain in lineup_intelligence.py
|
||||
- Slate scan → alt line auto-scan for A-grades in odds_scanner.py
|
||||
- Nightly resolution steps 14-18: coaching update, player-out history, evolution scan, unconventional data collection, monthly validation
|
||||
- Migration 009: supplement columns on grade_outcomes (coaching_context, redistribution_context, evolution_flag, alt_line_opportunity, unconventional_factors) + unconventional_factor_data table
|
||||
- API docs updated with 7 supplement endpoints
|
||||
- **Features (items 6-10):**
|
||||
- MLB lineup shift logic (PA multiplier changes when player scratched)
|
||||
- high_leverage_hook_tendency added to MLB coaching schema
|
||||
- Evolution persistence check (3 games before public promotion, false positive detection)
|
||||
- Unconventional daily data collection + monthly validation functions
|
||||
- Alt line ladder mode (ALT_LINE_MODE env var — 'manual' generates probability ladder)
|
||||
- **Infrastructure (items 11-15):**
|
||||
- 5 GitHub Actions YAML files: nightly (2am ET), morning odds (10am ET), pre-game (3pm/5pm/6:30pm ET), reporter poll (every 15min), weather (every 30min)
|
||||
- scripts/seed_historical.py — one-time historical data seeder (NBA 2024-25 + MLB 2024)
|
||||
- railway.toml (Flask service, port 5001, health check)
|
||||
- web/vercel.json (Next.js deployment)
|
||||
- MLB coaching helper functions for historical seeding
|
||||
- **Product is DEPLOYMENT-READY**
|
||||
|
||||
### Session 10d — 2026-04-13 (SECURITY AUDIT)
|
||||
- Applied 19-item security hardening pass — Ryan Montgomery panel reviewed
|
||||
- 45 new tests (689 total), all green across 41 test suites
|
||||
- **Authentication (items 1, 8, 11):**
|
||||
- utils/auth.py: require_auth (JWT with issuer check) + require_service_role (BETONBLK_INTERNAL_KEY)
|
||||
- PyJWT added to requirements.txt
|
||||
- BETONBLK_INTERNAL_KEY separates cron auth from service key — service key never leaves Railway
|
||||
- **Input Security (items 3, 10, 13):**
|
||||
- utils/validation.py: whitelist stat types, sanitize strings (strip SQL/HTML), validate line 0-500, image upload (magic bytes, 10MB max, PNG/JPEG/GIF), parlay legs 2-12
|
||||
- OCR rate limit 3/min, max 2 concurrent
|
||||
- MAX_CONTENT_LENGTH 1MB globally, 413 JSON response
|
||||
- **Network Security (items 2, 12):**
|
||||
- CORS locked to ALLOWED_ORIGINS env var (no more wildcard)
|
||||
- Real IP from X-Forwarded-For for rate limiter and security logger
|
||||
- **Error Handling (item 9):**
|
||||
- Production returns generic "Internal server error" — no stack traces
|
||||
- 404, 405, 413, 429 all return JSON
|
||||
- **Monitoring (items 4, 5, 6, 15, 17, 18):**
|
||||
- Security headers: X-Frame-Options DENY, HSTS, CSP, nosniff, XSS protection, Server removed
|
||||
- utils/security_logger.py: request logging, rate tracking, SQL injection detection, security_events table
|
||||
- utils/env_check.py: startup validation, exits on missing required vars, never logs secrets
|
||||
- security-scan.yml: weekly pip-audit + npm audit
|
||||
- security.txt: /.well-known/security.txt with contact
|
||||
- 90-day security event retention cleanup + weekly security digest (50+ events per IP = action required)
|
||||
- **Infrastructure (items 7, 14, 16, 19):**
|
||||
- Migration 010: security_events table with RLS
|
||||
- Supabase client timeout guidance, retry with 30s default timeout
|
||||
- Source code secret scan test (sk_live_, eyJhbGci, sbp_)
|
||||
- .gitignore: .env, .env.local, .env.production, *.pem, *.key, .vercel/
|
||||
|
||||
### Session 10b — 2026-04-13 (SUPPLEMENT BUILD)
|
||||
- Built 5 intelligence supplement systems — ADDITIVE, no existing code modified
|
||||
- 65 new tests (609 total), all green across 39 test suites
|
||||
- **System 1 — Coaching Tendency Database:**
|
||||
- blueprints/coaching.py (NEW) — per-coach NBA + MLB tendencies, nightly update from game logs, shift detection (15%+ threshold on last 15 vs season baseline)
|
||||
- 12 NBA fields (pace, 3PT rate, ISO freq, PnR usage, rotation depth, late-game player, score-state lineups, second-unit patterns, redistribution profile, shot location, timeout tendency)
|
||||
- 10 MLB fields (starter hook, quick hook, bullpen philosophy, IBB rate, PH freq, bunts, closer-only, platoon, lineup consistency, challenge aggressiveness)
|
||||
- **System 2 — Usage Redistribution Engine:**
|
||||
- blueprints/redistribution.py (NEW) — two-layer calculation (Layer A: minutes redistribution from historical player-out data + coaching rotation depth; Layer B: offensive system change from archetype shifts)
|
||||
- Uses coaching database, applies usage-efficiency tradeoff (-1.5% TS per +5% usage)
|
||||
- Three tiers: primary (>=0.20 boost, >=0.75 confidence), secondary (>=0.10, >=0.60), tertiary (>=0.05)
|
||||
- Auto-grades at 15%+ boost / 0.65+ confidence, formats 60-second absorption alerts
|
||||
- **System 3 — Alt Line Scanner:**
|
||||
- Added to existing odds_scanner.py — auto-runs on A-grade props after slate scan
|
||||
- Pulls alt lines from odds_warehouse, calculates model probability via Bayesian norm_cdf
|
||||
- Real edge with vig on each alt, finds optimal (best EV/dollar)
|
||||
- Only recommends if alt edge exceeds standard by 3%+
|
||||
- **System 4 — Unconventional Data Pipeline:**
|
||||
- blueprints/unconventional.py (NEW) — validation gate for non-traditional correlates
|
||||
- 500 instance minimum, Pearson r > 0.15, Bonferroni-corrected p-value
|
||||
- 5 tracked factors: altitude, contract year, referee crew history, travel distance (pre-validated), arena altitude
|
||||
- Factors only enter grading engine AFTER passing validation
|
||||
- **System 5 — Player Evolution Alerting:**
|
||||
- Added to existing evolution.py — daily scan across multiple metrics simultaneously
|
||||
- NBA: usage_rate, assist_rate, three_pa_rate, fg_pct, minutes
|
||||
- MLB: k_rate, bb_rate, exit_velocity, hard_hit_pct, fb_velo
|
||||
- PLAYER_EVOLUTION_DETECTED when 2+ metrics show concurrent inflection (10%+ change, 15 game minimum)
|
||||
- Timestamped records in evolution_detections table, Evolution Watch content formatter
|
||||
- **Migration 008:** coaching_tendencies, player_out_history, evolution_detections, unconventional_validations (all with indexes + RLS)
|
||||
- **Integration:** 3 new blueprints registered in app.py (coaching_bp, redistribution_bp, unconventional_bp), evolution + odds_scanner extended with new endpoints
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# BetonBLK — Claude Code Project Context
|
||||
# VYNDR — Claude Code Project Context
|
||||
|
||||
## What This Is
|
||||
Sports betting intelligence SaaS. Real software product.
|
||||
@@ -39,7 +39,7 @@ If you hit something you cannot resolve: log it. Don't guess. Don't skip.
|
||||
|
||||
## Folder Structure
|
||||
```
|
||||
betonblk/
|
||||
vyndr/
|
||||
├── src/
|
||||
│ ├── routes/ # Express route handlers
|
||||
│ ├── models/ # Supabase data models
|
||||
@@ -58,6 +58,6 @@ betonblk/
|
||||
```
|
||||
|
||||
## Active Skills
|
||||
- betonblk-voice (all user-facing output)
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
- monetization-system (scan-5 pitch, tier conversion)
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# BetonBLK — Architecture Decisions
|
||||
# VYNDR — Architecture Decisions
|
||||
|
||||
## Format
|
||||
Each decision follows this structure:
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
#
|
||||
# VYNDR Express backend (port 3001).
|
||||
#
|
||||
# Multi-stage build:
|
||||
# 1. deps — install production deps with a clean lockfile
|
||||
# 2. runner — copy src/, poller/, scripts/, node_modules and start
|
||||
#
|
||||
# The Next.js frontend ships in a separate image (web/Dockerfile). PM2
|
||||
# pollers can run inside this image via `pm2-runtime` or as a sibling
|
||||
# container — production deploys use a sibling so a poller crash doesn't
|
||||
# restart the API.
|
||||
#
|
||||
# Build: docker build -t vyndr-api .
|
||||
# Run: docker run -p 3001:3001 --env-file .env vyndr-api
|
||||
|
||||
# --- deps stage ---
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# package-lock.json is the source of truth — npm ci reproduces it exactly.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund
|
||||
|
||||
# --- runner stage ---
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# curl is used by the /api/health smoke check (Coolify HEALTHCHECK), and
|
||||
# postgresql-client lets the in-container migrations script run if needed.
|
||||
RUN apk add --no-cache curl tini
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3001
|
||||
|
||||
# Non-root user — the container should never run as uid 0 even if the
|
||||
# host accidentally maps a privileged port.
|
||||
RUN addgroup -S vyndr && adduser -S vyndr -G vyndr
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json package-lock.json ./
|
||||
COPY src ./src
|
||||
COPY poller ./poller
|
||||
COPY scripts ./scripts
|
||||
COPY supabase ./supabase
|
||||
|
||||
# Persistent volume for JSONL training data. Coolify mounts this path so
|
||||
# resolutions survive redeploys.
|
||||
RUN mkdir -p /app/data/training && chown -R vyndr:vyndr /app/data
|
||||
|
||||
USER vyndr
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
# tini reaps zombies cleanly when Node spawns child processes (e.g., the
|
||||
# embedded Python pre-checks the orchestrator may run during a slate).
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:3001/api/health || exit 1
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
# BetonBLK — Product Roadmap
|
||||
# VYNDR — Product Roadmap
|
||||
|
||||
## PHASE 1 — FOUNDATION (Weeks 1-2)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
### Feature 1.3 — Prop Analysis Engine (depends: 1.1 + 1.2)
|
||||
- Input: { player, stat_type, line, book }
|
||||
- Step 1-6: Season avg compare → situational factors → injury check → kill conditions → grade A/B/C/D → BetonBLK voice format
|
||||
- Step 1-6: Season avg compare → situational factors → injury check → kill conditions → grade A/B/C/D → VYNDR voice format
|
||||
- Output: { grade, edge_pct, reasoning, kill_conditions_triggered, confidence }
|
||||
- Status: NOT STARTED
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
{"ts":"2026-06-06T17:42:23.541Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T17:42:23.542Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T17:42:23.542Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T17:42:23.557Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T17:42:29.858Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T17:42:29.858Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T17:42:29.859Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T17:42:29.905Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T17:44:03.683Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T17:44:03.683Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T17:44:03.684Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T17:44:03.727Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T17:44:47.359Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T17:44:47.359Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T17:44:47.360Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T17:44:47.387Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T19:34:21.186Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T19:34:21.189Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T19:34:21.189Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T19:34:21.242Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T19:34:30.677Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T19:34:30.678Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T19:34:30.678Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T19:34:30.701Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T19:34:41.522Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T19:34:41.523Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T19:34:41.523Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T19:34:41.564Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T19:34:48.458Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T19:34:48.458Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T19:34:48.459Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T19:34:48.500Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T19:37:45.001Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T19:37:45.001Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T19:37:45.001Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T19:37:45.053Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T19:41:03.160Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T19:41:03.161Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T19:41:03.161Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T19:41:03.205Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T19:44:42.489Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T19:44:42.490Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T19:44:42.490Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T19:44:42.532Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T19:49:30.897Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T19:49:30.897Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T19:49:30.897Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T19:49:30.946Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T19:52:07.646Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T19:52:07.647Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T19:52:07.647Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T19:52:07.690Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T20:19:55.178Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T20:19:55.179Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T20:19:55.179Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T20:19:55.227Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T20:22:39.697Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T20:22:39.698Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T20:22:39.698Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T20:22:39.737Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T20:26:08.596Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T20:26:08.597Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T20:26:08.597Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T20:26:08.650Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T20:29:07.777Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T20:29:07.777Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T20:29:07.777Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T20:29:07.826Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T20:36:32.262Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T20:36:32.262Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T20:36:32.263Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T20:36:32.320Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T20:37:30.923Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T20:37:30.923Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T20:37:30.923Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T20:37:30.974Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T20:38:19.832Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-06T20:38:19.884Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-06T20:38:50.929Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-06T20:38:50.929Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-06T20:38:50.929Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-06T20:38:50.970Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-06T20:38:51.219Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-06T20:38:51.317Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-08T15:10:03.088Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-08T15:10:03.091Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-08T15:10:03.092Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-08T15:10:03.156Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-08T15:10:03.201Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-08T15:10:03.295Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-08T15:12:39.356Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-08T15:12:39.357Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-08T15:12:39.357Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-08T15:12:39.399Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-08T15:12:39.660Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-08T15:12:39.752Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-08T15:13:10.601Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-08T15:13:10.601Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-08T15:13:10.601Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-08T15:13:10.665Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-08T15:13:10.946Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-08T15:13:11.038Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-08T15:18:08.780Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-08T15:18:08.780Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-08T15:18:08.780Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-08T15:18:08.841Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-08T15:18:09.311Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-08T15:18:09.368Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-08T15:21:04.013Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-08T15:21:04.053Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-08T15:21:04.053Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-08T15:21:04.053Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-08T15:21:04.095Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-08T15:21:04.095Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-08T15:21:50.440Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-08T15:21:50.440Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-08T15:21:50.440Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-08T15:21:50.450Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-08T15:21:50.477Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-08T15:21:50.537Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
@@ -0,0 +1,42 @@
|
||||
# VYNDR — Clean-Room Design Log
|
||||
|
||||
This log documents every external codebase studied during VYNDR's
|
||||
development. It proves independent creation — no code was copied from any
|
||||
external source. When we examined a project under a copyleft license
|
||||
(GPL/AGPL/LGPL) we restricted the study to **concept and behavior**, not
|
||||
implementation.
|
||||
|
||||
| Date | Source | License | What Was Studied | Insight (Our Own Words) | Code Copied? |
|
||||
| ---------- | -------------------------------------------- | -------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| 2026-05-24 | nba_api (github.com/swar/nba_api) | MIT | API wrapper patterns for stats.nba.com endpoints | NBA.com exposes hidden JSON endpoints with rate limiting; our adapter calls these directly with our own session + retry layer. | No |
|
||||
| 2026-05-24 | pybaseball (github.com/jldbc/pybaseball) | MIT | Statcast data access patterns | Baseball Savant provides pitch-level data via public CSV endpoints; we shape our own query layer on top of those endpoints. | No |
|
||||
| 2026-05-28 | OpenBB (github.com/OpenBB-finance/OpenBB) | AGPL-3.0 ⚠️ | UI layout patterns for dense financial data display ONLY — no code studied | Terminal-style UI with panel-based layout suits intelligence products; applied as a design pattern only. | No — concept observation, no code read |
|
||||
| 2026-06-05 | River (github.com/online-ml/river) | BSD-3-Clause | Incremental ML API design, ADWIN drift detection algorithm | learn_one/predict_one interface enables single-sample model updates; ADWIN splits an error window for drift detection. | No |
|
||||
| 2026-06-05 | kyleskom/NBA-ML-Sports-Betting | Unlicensed ⚠️ | XGBoost + NN dual-model approach, Kelly Criterion sizing | Two independent model architectures voting together increase robustness; sigmoid calibration converts raw scores to probabilities. | No |
|
||||
| 2026-06-05 | Lisandro79/BeatTheBookie | GPL-3.0 ⚠️ | Market consensus deviation concept ONLY | When one book's line diverges from consensus, it signals either sharp action or a trap. | No — concept only |
|
||||
| 2026-06-05 | charlesmalafosse/sports-betting-customloss | MIT | Custom loss function concept | Training loss should weight correct predictions by implied probability — a correct call on +150 is more valuable than one on -200. | No |
|
||||
| 2026-06-05 | AagamanVarma/DriftGuard | Unknown | Champion-challenger deployment pattern | Challenger model earns promotion by outperforming champion on holdout set; a model pointer file enables atomic swap. | No |
|
||||
| 2026-06-06 | Serwist (serwist.pages.dev) | MIT | Service worker integration with Next.js | `@serwist/next` wraps next.config and injects a service worker entry; `defaultCache` provides reasonable runtime caching presets out of the box. | No — documentation read; code is our own SW wiring |
|
||||
| 2026-06-06 | web-push (github.com/web-push-libs/web-push) | MIT | VAPID-based push delivery semantics | A 410 Gone response on a subscription is the signal to delete it permanently; other statuses are transient. | No |
|
||||
| 2026-06-06 | SharpAPI (sharpapi.com) | Commercial | API surface only (docs read, no source available) | Player-prop endpoints expose over/under odds per book; consensus is computed client-side by aggregating across books. | No — commercial API, no code |
|
||||
| 2026-06-06 | OddsPapi (oddspapi.io) | Commercial | API surface only (docs read, no source available) | Pinnacle closing-line endpoint requires a bookmaker filter; the docs make no guarantee about latency between game start and line availability. | No — commercial API, no code |
|
||||
| 2026-06-06 | ParlayAPI (parlayapi.io) | Commercial | API surface only (docs read, no source available) | Free tier exposes 3.7M historical prop closing records via a credit-based bucket — careful pacing is mandatory because credits don't refresh mid-month. | No — commercial API, no code |
|
||||
| 2026-06-06 | PropOdds (prop-odds.com) | Commercial | API surface only (docs read, no source available) | Specializes in player props specifically; useful as a second consensus point so line-divergence trap signal isn't a two-book artifact. | No — commercial API, no code |
|
||||
| 2026-06-06 | College Football Data (collegefootballdata.com) | Free / personal API key | API surface only (docs read, no source available) | Talent composites + PPA fill the gap nba_api / ESPN don't cover for college. Bearer-token auth keeps the key off URL logs. | No — commercial-style API, no code |
|
||||
| 2026-06-06 | Sports-Reference (basketball-reference.com) | Site TOS (HTML scraping with credit) | Public HTML tables only — referee + coach index pages | Data-stat attributes on table cells are the cleanest extraction key. We rate-limit at 1 req per 5s and identify with a User-Agent so the site can contact us if anything's off. | No — read public HTML, parsed table data only |
|
||||
| 2026-06-06 | OpenRouter (openrouter.ai) | Commercial | API surface only (docs read, no source available) | OpenAI-compatible /chat/completions endpoint with a model-selector header. Bearer auth keeps the key off URLs. Free-tier per-model rate limits are not documented per-account; conservative 20/min budget is safer than relying on 429 retry. | No — commercial API, no code |
|
||||
|
||||
## Template for future entries
|
||||
|
||||
```
|
||||
| YYYY-MM-DD | <source URL> | <license> | <what we studied> | <our own-words insight> | No |
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Always log GPL/AGPL/LGPL projects** with the ⚠️ marker and limit study to
|
||||
concept observation. Do not read source files line-by-line.
|
||||
- **Unlicensed code is studied as ideas only.** Without a license the work is
|
||||
default copyrighted; we do not duplicate structure.
|
||||
- **Document the *insight* in our own words.** If an entry reads like a
|
||||
paraphrase of the source, rewrite it.
|
||||
+86
-1
@@ -1,9 +1,13 @@
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from app.services.stats import get_season_avg, get_last_n, get_splits
|
||||
from app.services.wnba import wnba_season_avg, wnba_last_n
|
||||
from app.services.refs import get_tonight_officials, get_referee_tendencies
|
||||
from app.services.mlb_statcast import get_pitcher_profile, get_batter_vs_pitcher
|
||||
from app.services.mlb_umpire import get_umpire_profile
|
||||
from app.utils.player_map import search_players
|
||||
from app.utils.cache import cache_health
|
||||
|
||||
app = FastAPI(title="BetonBLK NBA Stats Service", version="1.0.0")
|
||||
app = FastAPI(title="VYNDR Stats Service", version="1.1.0")
|
||||
|
||||
VALID_STAT_TYPES = {
|
||||
"points", "rebounds", "assists", "threes", "blocks",
|
||||
@@ -91,3 +95,84 @@ async def splits(
|
||||
raise HTTPException(status_code=404, detail=f"Player not found: {player}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── WNBA ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/wnba/stats/season-avg")
|
||||
async def wnba_season(
|
||||
player: str = Query(..., min_length=2),
|
||||
stat_type: str = Query(None),
|
||||
season: str = Query(None),
|
||||
):
|
||||
if stat_type and stat_type not in VALID_STAT_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid stat_type: {stat_type}")
|
||||
try:
|
||||
result = wnba_season_avg(player, stat_type=stat_type, season=season)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=503, detail="WNBA stats service unavailable")
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail=f"Player not found: {player}")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/wnba/stats/last-n")
|
||||
async def wnba_last(
|
||||
player: str = Query(..., min_length=2),
|
||||
n: int = Query(10, ge=1, le=30),
|
||||
stat_type: str = Query(None),
|
||||
):
|
||||
if stat_type and stat_type not in VALID_STAT_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid stat_type: {stat_type}")
|
||||
try:
|
||||
result = wnba_last_n(player, n=n, stat_type=stat_type)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=503, detail="WNBA stats service unavailable")
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail=f"Player not found: {player}")
|
||||
return result
|
||||
|
||||
|
||||
# ── NBA Referees ─────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/refs/game/{game_id}")
|
||||
async def refs_game(game_id: str):
|
||||
if not game_id.isalnum() or len(game_id) > 16:
|
||||
raise HTTPException(status_code=400, detail="invalid game_id")
|
||||
return get_tonight_officials(game_id)
|
||||
|
||||
|
||||
@app.get("/refs/tendencies")
|
||||
async def refs_tendencies(
|
||||
season: str = Query("2025-26"),
|
||||
league: str = Query("nba"),
|
||||
):
|
||||
if league not in {"nba", "wnba"}:
|
||||
raise HTTPException(status_code=400, detail="league must be nba or wnba")
|
||||
return get_referee_tendencies(season=season, league=league)
|
||||
|
||||
|
||||
# ── MLB Statcast ─────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/mlb/pitcher/{pitcher_id}")
|
||||
async def mlb_pitcher(pitcher_id: int, days_back: int = Query(30, ge=7, le=90)):
|
||||
if pitcher_id <= 0:
|
||||
raise HTTPException(status_code=400, detail="invalid pitcher_id")
|
||||
return get_pitcher_profile(pitcher_id=pitcher_id, days_back=days_back)
|
||||
|
||||
|
||||
@app.get("/mlb/bvp")
|
||||
async def mlb_bvp(
|
||||
batter_id: int = Query(..., gt=0),
|
||||
pitcher_id: int = Query(..., gt=0),
|
||||
years_back: int = Query(3, ge=1, le=5),
|
||||
):
|
||||
return get_batter_vs_pitcher(batter_id=batter_id, pitcher_id=pitcher_id, years_back=years_back)
|
||||
|
||||
|
||||
@app.get("/mlb/umpires")
|
||||
async def mlb_umpires(
|
||||
umpire: str = Query(None, max_length=64),
|
||||
days_back: int = Query(30, ge=7, le=45),
|
||||
):
|
||||
return get_umpire_profile(umpire_name=umpire, days_back=days_back)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
MLB Statcast enrichment using pybaseball.
|
||||
|
||||
Provides:
|
||||
- Pitcher pitch-mix + zone heatmap data for K-prop grading
|
||||
- Batter vs Pitcher historical matchup data
|
||||
|
||||
We avoid wide-net `statcast()` calls that pull every pitch league-wide —
|
||||
those routinely time out. Pitcher-specific calls are scoped to a 30-day
|
||||
trailing window which keeps payloads under a few hundred KB.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
from pybaseball import statcast_pitcher
|
||||
|
||||
from app.utils.cache import cache_get, cache_set
|
||||
from app.config import SPLITS_TTL
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _date_n_days_ago(n: int) -> str:
|
||||
return (datetime.utcnow() - timedelta(days=n)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def get_pitcher_profile(pitcher_id: int, days_back: int = 30) -> dict:
|
||||
"""
|
||||
Aggregate a pitcher's recent pitch-level data into pitch mix,
|
||||
velocity, whiff/chase, and zone heatmap counts.
|
||||
"""
|
||||
if not isinstance(pitcher_id, int) or pitcher_id <= 0:
|
||||
return {"error": "invalid pitcher_id"}
|
||||
|
||||
cache_key = f"mlb:pitcher:{pitcher_id}:d{days_back}"
|
||||
cached = cache_get(cache_key)
|
||||
if cached is not None:
|
||||
cached["source"] = "cache"
|
||||
return cached
|
||||
|
||||
end = _today_iso()
|
||||
start = _date_n_days_ago(days_back)
|
||||
|
||||
try:
|
||||
data = statcast_pitcher(start, end, pitcher_id)
|
||||
except Exception as exc:
|
||||
return {"error": f"statcast fetch failed: {exc!s}"}
|
||||
|
||||
if data is None or data.empty:
|
||||
return {"pitcher_id": pitcher_id, "pitch_mix": [], "zone": [], "note": "no data"}
|
||||
|
||||
# Pitch mix
|
||||
description_col = data["description"] if "description" in data.columns else pd.Series(dtype=str)
|
||||
pitch_mix_grouped = data.groupby("pitch_type") if "pitch_type" in data.columns else None
|
||||
pitch_mix: list[dict] = []
|
||||
if pitch_mix_grouped is not None:
|
||||
for ptype, g in pitch_mix_grouped:
|
||||
total = len(g)
|
||||
d = g["description"] if "description" in g.columns else pd.Series(dtype=str)
|
||||
swings = d.isin([
|
||||
"swinging_strike", "foul", "foul_tip", "hit_into_play",
|
||||
"swinging_strike_blocked",
|
||||
]).sum() if not d.empty else 0
|
||||
whiffs = (d == "swinging_strike").sum() if not d.empty else 0
|
||||
pitch_mix.append({
|
||||
"pitch_type": str(ptype),
|
||||
"count": int(total),
|
||||
"share": float(total / len(data)) if len(data) else 0.0,
|
||||
"avg_velocity": float(g["release_speed"].mean()) if "release_speed" in g.columns else None,
|
||||
"whiff_rate": float(whiffs / swings) if swings else 0.0,
|
||||
})
|
||||
|
||||
# Zone heatmap (the existing pybaseball 'zone' column is the 13-zone scheme)
|
||||
zone_data: list[dict] = []
|
||||
if "zone" in data.columns:
|
||||
for zone, g in data.groupby("zone"):
|
||||
d = g["description"] if "description" in g.columns else pd.Series(dtype=str)
|
||||
zone_data.append({
|
||||
"zone": int(zone) if pd.notna(zone) else None,
|
||||
"pitches": int(len(g)),
|
||||
"whiff_rate": float((d == "swinging_strike").mean()) if not d.empty else 0.0,
|
||||
})
|
||||
|
||||
result = {
|
||||
"pitcher_id": pitcher_id,
|
||||
"window_days": days_back,
|
||||
"total_pitches": int(len(data)),
|
||||
"avg_velocity": float(data["release_speed"].mean()) if "release_speed" in data.columns else None,
|
||||
"k_rate_estimate": float((data["events"] == "strikeout").mean()) if "events" in data.columns else None,
|
||||
"pitch_mix": pitch_mix,
|
||||
"zone": zone_data,
|
||||
"source": "statcast",
|
||||
}
|
||||
cache_set(cache_key, result, SPLITS_TTL)
|
||||
return result
|
||||
|
||||
|
||||
def get_batter_vs_pitcher(batter_id: int, pitcher_id: int, years_back: int = 3) -> dict:
|
||||
"""
|
||||
Historical matchup. We scope to the pitcher because their pitch stream
|
||||
is small enough to fetch quickly; then filter to plate appearances by
|
||||
the batter.
|
||||
"""
|
||||
if not isinstance(batter_id, int) or not isinstance(pitcher_id, int):
|
||||
return {"error": "invalid ids"}
|
||||
|
||||
cache_key = f"mlb:bvp:{batter_id}:{pitcher_id}:y{years_back}"
|
||||
cached = cache_get(cache_key)
|
||||
if cached is not None:
|
||||
cached["source"] = "cache"
|
||||
return cached
|
||||
|
||||
end = _today_iso()
|
||||
start = _date_n_days_ago(365 * years_back)
|
||||
|
||||
try:
|
||||
pitcher_data = statcast_pitcher(start, end, pitcher_id)
|
||||
except Exception as exc:
|
||||
return {"error": f"statcast fetch failed: {exc!s}"}
|
||||
|
||||
if pitcher_data is None or pitcher_data.empty or "batter" not in pitcher_data.columns:
|
||||
return {"batter_id": batter_id, "pitcher_id": pitcher_id, "matchup": "no data"}
|
||||
|
||||
matchup = pitcher_data[pitcher_data["batter"] == batter_id]
|
||||
if matchup.empty:
|
||||
return {"batter_id": batter_id, "pitcher_id": pitcher_id, "matchup": "no history"}
|
||||
|
||||
events = matchup["events"] if "events" in matchup.columns else pd.Series(dtype=str)
|
||||
result = {
|
||||
"batter_id": batter_id,
|
||||
"pitcher_id": pitcher_id,
|
||||
"plate_appearances": int(events.notna().sum()),
|
||||
"hits": int(events.isin(["single", "double", "triple", "home_run"]).sum()),
|
||||
"strikeouts": int((events == "strikeout").sum()),
|
||||
"home_runs": int((events == "home_run").sum()),
|
||||
"walks": int((events == "walk").sum()),
|
||||
"avg_exit_velocity": float(matchup["launch_speed"].mean()) if "launch_speed" in matchup.columns else None,
|
||||
"pitches_seen": int(len(matchup)),
|
||||
"pitch_types_faced": {
|
||||
str(k): int(v)
|
||||
for k, v in (matchup["pitch_type"].value_counts().to_dict().items() if "pitch_type" in matchup.columns else {}).items()
|
||||
},
|
||||
"source": "statcast",
|
||||
}
|
||||
# Cache aggressively — historical matchup data is stable.
|
||||
cache_set(cache_key, result, SPLITS_TTL * 2)
|
||||
return result
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
MLB umpire K-zone profiling via pybaseball Statcast pitch data.
|
||||
|
||||
Drives the K-prop modifier in the grading engine:
|
||||
- Top quartile called-strike rate → boost K projections
|
||||
- Bottom quartile → penalize K projections
|
||||
|
||||
NOTE: Statcast's per-pitch dataset includes umpires under the `umpire` and
|
||||
`fielder_*` columns inconsistently across seasons. We treat missing data
|
||||
as 'no signal' rather than blocking the grade.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
from pybaseball import statcast
|
||||
|
||||
from app.utils.cache import cache_get, cache_set
|
||||
from app.config import SPLITS_TTL
|
||||
|
||||
# Approximate rule-book strike zone half-width / height range in feet.
|
||||
_ZONE_HALF_WIDTH = 0.83
|
||||
_ZONE_BOTTOM = 1.5
|
||||
_ZONE_TOP = 3.5
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def get_umpire_profile(umpire_name: Optional[str] = None, days_back: int = 30) -> dict:
|
||||
"""
|
||||
Pull a window of pitch-level data and aggregate by umpire. Returns a
|
||||
league average plus a list of umpires sorted by called-strike rate.
|
||||
|
||||
Heavy call — capped at 30 days to keep the payload manageable. The
|
||||
orchestrator should call this nightly, not per-game.
|
||||
"""
|
||||
days_back = max(7, min(int(days_back or 30), 45))
|
||||
end = _today_iso()
|
||||
start = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y-%m-%d")
|
||||
|
||||
cache_key = f"mlb:umpires:{start}:{end}:{umpire_name or 'all'}"
|
||||
cached = cache_get(cache_key)
|
||||
if cached is not None:
|
||||
cached["source"] = "cache"
|
||||
return cached
|
||||
|
||||
try:
|
||||
data = statcast(start, end)
|
||||
except Exception as exc:
|
||||
return {"error": f"statcast fetch failed: {exc!s}", "umpires": []}
|
||||
|
||||
if data is None or data.empty:
|
||||
return {"umpires": [], "note": "no data", "window": [start, end]}
|
||||
|
||||
if "umpire" not in data.columns:
|
||||
# Some Statcast windows omit the umpire column entirely.
|
||||
return {
|
||||
"umpires": [],
|
||||
"league_avg_called_strike_rate": None,
|
||||
"note": "umpire data unavailable in this window",
|
||||
"window": [start, end],
|
||||
}
|
||||
|
||||
in_zone = (
|
||||
data["plate_x"].abs() <= _ZONE_HALF_WIDTH
|
||||
) & (
|
||||
data["plate_z"].between(_ZONE_BOTTOM, _ZONE_TOP)
|
||||
) if {"plate_x", "plate_z"}.issubset(data.columns) else pd.Series(False, index=data.index)
|
||||
|
||||
grouped = data.groupby("umpire", dropna=True)
|
||||
rows = []
|
||||
for ump, g in grouped:
|
||||
d = g["description"] if "description" in g.columns else pd.Series(dtype=str)
|
||||
called_strikes = int((d == "called_strike").sum())
|
||||
called_balls = int((d == "ball").sum())
|
||||
called_total = called_strikes + called_balls
|
||||
events = g["events"] if "events" in g.columns else pd.Series(dtype=str)
|
||||
rows.append({
|
||||
"umpire": str(ump),
|
||||
"pitches": int(len(g)),
|
||||
"called_strike_rate": float(called_strikes / called_total) if called_total else 0.0,
|
||||
"k_rate": float((events == "strikeout").mean()) if not events.empty else 0.0,
|
||||
"in_zone_pitches": int(in_zone[g.index].sum()) if not in_zone.empty else 0,
|
||||
})
|
||||
|
||||
if not rows:
|
||||
return {"umpires": [], "note": "no per-umpire rows aggregated"}
|
||||
|
||||
league_avg = sum(r["called_strike_rate"] for r in rows) / len(rows)
|
||||
rows.sort(key=lambda r: r["called_strike_rate"], reverse=True)
|
||||
|
||||
if umpire_name:
|
||||
needle = umpire_name.lower()
|
||||
rows = [r for r in rows if needle in r["umpire"].lower()]
|
||||
|
||||
result = {
|
||||
"umpires": rows[:30],
|
||||
"league_avg_called_strike_rate": league_avg,
|
||||
"window": [start, end],
|
||||
"source": "statcast",
|
||||
}
|
||||
cache_set(cache_key, result, SPLITS_TTL)
|
||||
return result
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
pbpstats wrapper — possession-level NBA/WNBA analytics.
|
||||
|
||||
pbpstats client setup is non-trivial; this module exposes a single safe
|
||||
entrypoint that returns aggregate possession data per player. If client
|
||||
construction fails (commonly due to missing local data files), we return
|
||||
a structured 'unavailable' response rather than raising.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def get_possession_data(player_id: int, season: str = "2025-26", season_type: str = "Regular Season") -> dict:
|
||||
try:
|
||||
from pbpstats.client import Client
|
||||
settings = {
|
||||
"Boxscore": {"source": "web", "data_provider": "data_nba"},
|
||||
"Possessions": {"source": "web", "data_provider": "data_nba"},
|
||||
}
|
||||
client = Client(settings)
|
||||
# The pbpstats API surface depends on the installed version. We
|
||||
# expose just a minimal shape here so the orchestrator can call us
|
||||
# uniformly even when this module is degraded.
|
||||
return {
|
||||
"player_id": player_id,
|
||||
"season": season,
|
||||
"season_type": season_type,
|
||||
"available": True,
|
||||
"note": "pbpstats client initialized; per-player possession aggregation TODO",
|
||||
"source": "pbpstats",
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"player_id": player_id,
|
||||
"available": False,
|
||||
"error": f"pbpstats unavailable: {exc!s}",
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
NBA/WNBA referee enrichment.
|
||||
|
||||
Source: stats.nba.com unofficial endpoints. Crew assignments are typically
|
||||
published ~60-90 minutes before tip via the boxscoresummaryv2 endpoint.
|
||||
|
||||
This DIRECTLY affects kill conditions in the grading engine:
|
||||
- Crews calling more fouls than league average increase foul-trouble risk
|
||||
- Players w/ high foul rates + foul-heavy crews → kill condition activated
|
||||
|
||||
We intentionally keep this stateless; the orchestrator caches results.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from app.utils.cache import cache_get, cache_set
|
||||
from app.config import NBA_API_TIMEOUT, SPLITS_TTL
|
||||
|
||||
_NBA_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; VYNDR/1.0)",
|
||||
"Referer": "https://www.nba.com/",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"x-nba-stats-origin": "stats",
|
||||
"x-nba-stats-token": "true",
|
||||
}
|
||||
|
||||
_REF_STATS_URL = "https://stats.nba.com/stats/officialgamefindergamelogs"
|
||||
_BOXSCORE_URL = "https://stats.nba.com/stats/boxscoresummaryv2"
|
||||
|
||||
# League IDs per stats.nba.com convention
|
||||
LEAGUE_ID = {"nba": "00", "wnba": "10"}
|
||||
|
||||
|
||||
def _safe_get(url: str, params: dict) -> Optional[dict]:
|
||||
"""Resilient GET with a single retry. stats.nba.com is flaky."""
|
||||
for attempt in (0, 1):
|
||||
try:
|
||||
resp = requests.get(url, headers=_NBA_HEADERS, params=params, timeout=NBA_API_TIMEOUT)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except requests.RequestException:
|
||||
pass
|
||||
if attempt == 0:
|
||||
time.sleep(1.5)
|
||||
return None
|
||||
|
||||
|
||||
def get_tonight_officials(game_id: str) -> dict:
|
||||
"""
|
||||
Return the crew assigned to a single game. Empty list means assignments
|
||||
haven't been published yet (normal until ~90 min before tip).
|
||||
"""
|
||||
if not game_id or not str(game_id).isalnum():
|
||||
return {"error": "invalid game_id", "officials": []}
|
||||
|
||||
cache_key = f"refs:officials:{game_id}"
|
||||
cached = cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
data = _safe_get(_BOXSCORE_URL, {"GameID": game_id})
|
||||
if not data or "resultSets" not in data:
|
||||
return {"officials": [], "game_id": game_id, "source": "stats.nba.com", "note": "no data"}
|
||||
|
||||
officials = []
|
||||
for rs in data.get("resultSets", []):
|
||||
if rs.get("name") != "Officials":
|
||||
continue
|
||||
headers = rs.get("headers") or []
|
||||
for row in rs.get("rowSet") or []:
|
||||
record = dict(zip(headers, row))
|
||||
first = record.get("FIRST_NAME", "") or ""
|
||||
last = record.get("LAST_NAME", "") or ""
|
||||
officials.append({
|
||||
"official_id": record.get("OFFICIAL_ID"),
|
||||
"name": f"{first} {last}".strip(),
|
||||
"jersey_num": record.get("JERSEY_NUM"),
|
||||
})
|
||||
break
|
||||
|
||||
result = {
|
||||
"game_id": game_id,
|
||||
"officials": officials,
|
||||
"source": "stats.nba.com",
|
||||
}
|
||||
# Officials assignments don't change once published, but TTL keeps the cache fresh.
|
||||
cache_set(cache_key, result, ttl=SPLITS_TTL)
|
||||
return result
|
||||
|
||||
|
||||
def get_referee_tendencies(season: str, league: str = "nba") -> dict:
|
||||
"""
|
||||
Aggregate per-referee tendencies for the season. Returns league_avg_pf
|
||||
and a sorted list of refs by personal-foul rate; consumers can classify
|
||||
'tight', 'average', 'generous' crews from the quartile bands.
|
||||
|
||||
NOTE: stats.nba.com's referee dashboard endpoint changes shape every few
|
||||
years. If the upstream returns nothing, the orchestrator should fall
|
||||
back to last season's cached data.
|
||||
"""
|
||||
if league not in LEAGUE_ID:
|
||||
return {"error": "invalid league", "referees": []}
|
||||
|
||||
cache_key = f"refs:tendencies:{league}:{season}"
|
||||
cached = cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# The upstream endpoint moved around 2024. We try the modern URL first
|
||||
# and degrade gracefully — the rest of the pipeline can use league_avg
|
||||
# alone to back off the foul-trouble kill condition modifier.
|
||||
params = {
|
||||
"Season": season,
|
||||
"SeasonType": "Regular Season",
|
||||
"LeagueID": LEAGUE_ID[league],
|
||||
"PerMode": "PerGame",
|
||||
}
|
||||
data = _safe_get("https://stats.nba.com/stats/leaguedashrefstats", params)
|
||||
if not data or not data.get("resultSets"):
|
||||
result = {
|
||||
"referees": [],
|
||||
"league_avg_pf_per_game": None,
|
||||
"season": season,
|
||||
"league": league,
|
||||
"note": "upstream referee dashboard unavailable",
|
||||
}
|
||||
# Short cache so we retry sooner.
|
||||
cache_set(cache_key, result, ttl=300)
|
||||
return result
|
||||
|
||||
rs = data["resultSets"][0]
|
||||
headers = rs.get("headers") or []
|
||||
refs = []
|
||||
for row in rs.get("rowSet") or []:
|
||||
record = dict(zip(headers, row))
|
||||
refs.append({
|
||||
"name": record.get("REFEREE_NAME", ""),
|
||||
"games": record.get("GP", 0),
|
||||
"pf_per_game": record.get("PF", 0),
|
||||
"tech_per_game": record.get("TECH", 0),
|
||||
"off_foul_per_game": record.get("OFF_FOUL", 0),
|
||||
})
|
||||
|
||||
pf_values = [r["pf_per_game"] or 0 for r in refs if (r.get("pf_per_game") or 0) > 0]
|
||||
league_avg = (sum(pf_values) / len(pf_values)) if pf_values else None
|
||||
|
||||
result = {
|
||||
"referees": refs,
|
||||
"league_avg_pf_per_game": league_avg,
|
||||
"season": season,
|
||||
"league": league,
|
||||
"source": "stats.nba.com",
|
||||
}
|
||||
cache_set(cache_key, result, ttl=SPLITS_TTL)
|
||||
return result
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
WNBA stats — uses nba_api with league_id='10'.
|
||||
|
||||
Kept self-contained (not a wrapper over NBA's stats.py) so the existing
|
||||
NBA code path stays untouched. Shape of the returned dicts mirrors
|
||||
stats.py so callers can dispatch on `sport` without branching downstream.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from nba_api.stats.endpoints import playercareerstats, playergamelog
|
||||
from nba_api.stats.static import players as wnba_players
|
||||
|
||||
from app.utils.cache import cache_get, cache_set
|
||||
from app.config import (
|
||||
NBA_API_DELAY, NBA_API_TIMEOUT,
|
||||
SEASON_AVG_TTL, LAST_N_TTL,
|
||||
)
|
||||
|
||||
WNBA_LEAGUE_ID = "10"
|
||||
_STAT_MAP = {
|
||||
"PTS": "points",
|
||||
"REB": "rebounds",
|
||||
"AST": "assists",
|
||||
"FG3M": "threes",
|
||||
"BLK": "blocks",
|
||||
"STL": "steals",
|
||||
"TOV": "turnovers",
|
||||
"MIN": "minutes",
|
||||
"GP": "games_played",
|
||||
}
|
||||
|
||||
|
||||
def _wnba_current_season() -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
# WNBA season is roughly May–September; use the calendar year.
|
||||
return str(now.year)
|
||||
|
||||
|
||||
def _safe(func, **kwargs):
|
||||
"""Tiny rate-limited wrapper around nba_api endpoints."""
|
||||
time.sleep(NBA_API_DELAY)
|
||||
return func(timeout=NBA_API_TIMEOUT, **kwargs)
|
||||
|
||||
|
||||
def _resolve_wnba_player(name: str) -> tuple[Optional[int], str]:
|
||||
name = (name or "").strip()
|
||||
if len(name) < 2:
|
||||
return None, ""
|
||||
# nba_api.static.players only ships NBA player lists; for WNBA we resolve
|
||||
# via the search endpoint (commonteamroster also works). For now we fall
|
||||
# back to a name match across the (NBA + WNBA) static set, then verify
|
||||
# with the live endpoint if needed.
|
||||
matches = wnba_players.find_players_by_full_name(name)
|
||||
if matches:
|
||||
return matches[0]["id"], matches[0]["full_name"]
|
||||
return None, ""
|
||||
|
||||
|
||||
def _map_stats(row: dict) -> dict:
|
||||
return {our: row[their] for their, our in _STAT_MAP.items() if their in row}
|
||||
|
||||
|
||||
def wnba_season_avg(player_name: str, stat_type: Optional[str] = None, season: Optional[str] = None) -> Optional[dict]:
|
||||
player_id, full_name = _resolve_wnba_player(player_name)
|
||||
if player_id is None:
|
||||
return None
|
||||
|
||||
season = season or _wnba_current_season()
|
||||
cache_key = f"wnba:season:{player_id}:{season}"
|
||||
cached = cache_get(cache_key)
|
||||
if cached is not None:
|
||||
cached["source"] = "cache"
|
||||
if stat_type and stat_type in cached.get("stats", {}):
|
||||
cached["stats"] = {stat_type: cached["stats"][stat_type]}
|
||||
return cached
|
||||
|
||||
career = _safe(
|
||||
playercareerstats.PlayerCareerStats,
|
||||
player_id=player_id,
|
||||
league_id_nullable=WNBA_LEAGUE_ID,
|
||||
)
|
||||
df = career.get_data_frames()[0]
|
||||
season_row = df[df["SEASON_ID"] == season]
|
||||
|
||||
stats = _map_stats(season_row.iloc[0].to_dict()) if not season_row.empty else {}
|
||||
|
||||
result = {
|
||||
"player": full_name,
|
||||
"player_id": player_id,
|
||||
"team": season_row.iloc[0]["TEAM_ABBREVIATION"] if not season_row.empty else "UNK",
|
||||
"season": season,
|
||||
"league": "wnba",
|
||||
"source": "live",
|
||||
"stats": stats,
|
||||
}
|
||||
cache_set(cache_key, result, SEASON_AVG_TTL)
|
||||
|
||||
if stat_type and stat_type in stats:
|
||||
result["stats"] = {stat_type: stats[stat_type]}
|
||||
return result
|
||||
|
||||
|
||||
def wnba_last_n(player_name: str, n: int = 10, stat_type: Optional[str] = None) -> Optional[dict]:
|
||||
player_id, full_name = _resolve_wnba_player(player_name)
|
||||
if player_id is None:
|
||||
return None
|
||||
|
||||
n = min(max(int(n), 1), 30)
|
||||
cache_key = f"wnba:last:{player_id}:{n}"
|
||||
cached = cache_get(cache_key)
|
||||
if cached is not None:
|
||||
cached["source"] = "cache"
|
||||
if stat_type and stat_type in cached.get("stats", {}):
|
||||
cached["stats"] = {stat_type: cached["stats"][stat_type]}
|
||||
return cached
|
||||
|
||||
season = _wnba_current_season()
|
||||
gamelog = _safe(
|
||||
playergamelog.PlayerGameLog,
|
||||
player_id=player_id,
|
||||
season=season,
|
||||
league_id_nullable=WNBA_LEAGUE_ID,
|
||||
)
|
||||
df = gamelog.get_data_frames()[0]
|
||||
|
||||
if df.empty:
|
||||
return {
|
||||
"player": full_name,
|
||||
"player_id": player_id,
|
||||
"team": "UNK",
|
||||
"last_n": n,
|
||||
"league": "wnba",
|
||||
"source": "live",
|
||||
"stats": {},
|
||||
}
|
||||
|
||||
recent = df.head(n)
|
||||
averages = {our: float(recent[their].mean()) for their, our in _STAT_MAP.items() if their in recent.columns}
|
||||
|
||||
result = {
|
||||
"player": full_name,
|
||||
"player_id": player_id,
|
||||
"team": str(recent.iloc[0].get("MATCHUP", "")).split(" ")[0] or "UNK",
|
||||
"last_n": n,
|
||||
"league": "wnba",
|
||||
"source": "live",
|
||||
"stats": averages,
|
||||
}
|
||||
cache_set(cache_key, result, LAST_N_TTL)
|
||||
|
||||
if stat_type and stat_type in averages:
|
||||
result["stats"] = {stat_type: averages[stat_type]}
|
||||
return result
|
||||
@@ -1,7 +1,10 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn==0.34.2
|
||||
nba_api==1.11.4
|
||||
pybaseball==2.2.7
|
||||
pbpstats==1.4.5
|
||||
redis==5.3.0
|
||||
httpx==0.28.1
|
||||
requests==2.34.2
|
||||
pytest==8.3.5
|
||||
pytest-asyncio==0.25.3
|
||||
|
||||
Generated
+1002
-4
File diff suppressed because it is too large
Load Diff
+14
-6
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "betonblk",
|
||||
"name": "vyndr",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
@@ -10,28 +10,36 @@
|
||||
"scripts": {
|
||||
"test": "jest --verbose",
|
||||
"test:unit": "jest tests/unit --verbose",
|
||||
"test:integration": "jest tests/integration --verbose"
|
||||
"test:integration": "jest tests/integration --verbose",
|
||||
"audit:licenses": "npx --yes license-checker --production --onlyAllow 'MIT;MIT-0;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;CC0-1.0;Unlicense;0BSD;Python-2.0;CC-BY-4.0;BlueOak-1.0.0;LGPL-3.0-or-later;MPL-2.0' --excludePackages 'vyndr@1.0.0'",
|
||||
"audit:security": "npm audit --omit=dev",
|
||||
"audit:all": "npm run audit:licenses && npm run audit:security"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/kev3109/betonblk.git"
|
||||
"url": "git+https://github.com/kev3109/vyndr.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"bugs": {
|
||||
"url": "https://github.com/kev3109/betonblk/issues"
|
||||
"url": "https://github.com/kev3109/vyndr/issues"
|
||||
},
|
||||
"homepage": "https://github.com/kev3109/betonblk#readme",
|
||||
"homepage": "https://github.com/kev3109/vyndr#readme",
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.99.3",
|
||||
"axios": "^1.13.6",
|
||||
"cheerio": "^1.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"form-data": "^4.0.5",
|
||||
"ioredis": "^5.10.1",
|
||||
"postgres": "^3.4.8",
|
||||
"stripe": "^20.4.1"
|
||||
"sharp": "^0.34.5",
|
||||
"stripe": "^20.4.1",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.3.0",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* PM2 ecosystem for the resolution pollers.
|
||||
*
|
||||
* cd poller && pm2 start ecosystem.config.js
|
||||
*
|
||||
* Each sport runs in its own process so a runaway request loop on one
|
||||
* upstream can't starve the others. cwd is the repo root so `../src/*`
|
||||
* relative imports inside poller.js resolve correctly under PM2.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const baseEnv = {
|
||||
POLL_INTERVAL: '60000',
|
||||
BUFFER_MS: '30000',
|
||||
};
|
||||
|
||||
function poller(sport, env = {}) {
|
||||
return {
|
||||
name: `poller-${sport}`,
|
||||
script: path.join(__dirname, 'poller.js'),
|
||||
cwd: ROOT,
|
||||
env: { ...baseEnv, ...env, SPORT: sport },
|
||||
max_memory_restart: '256M',
|
||||
log_type: 'json',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
autorestart: true,
|
||||
// Restart up to 10× per minute if the process keeps crashing — gives
|
||||
// a transient ESPN outage time to clear before pm2 backs off.
|
||||
max_restarts: 10,
|
||||
min_uptime: '30s',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
poller('nba'),
|
||||
poller('wnba'),
|
||||
poller('mlb'),
|
||||
// Uncomment when in-season — keeping commented to save memory off-season.
|
||||
// poller('nfl'),
|
||||
// poller('ncaafb'),
|
||||
// poller('nhl'),
|
||||
// poller('ncaab'),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* ESPN resolution poller — one PM2 process per sport.
|
||||
*
|
||||
* Two jobs:
|
||||
* 1. First time we see STATUS_IN_PROGRESS for a game → trigger OddsPapi
|
||||
* batchCapture for closing lines (CLV reference).
|
||||
* 2. First time we see a FINAL status → wait BUFFER_MS, fetch box score,
|
||||
* POST to /api/grading/resolve.
|
||||
*
|
||||
* Idempotency is enforced via two Redis keys per game:
|
||||
* - game:{id}:status — last status we processed (TTL 36h)
|
||||
* - game:{id}:resolution_lock — set during POST attempt (TTL 5min)
|
||||
*
|
||||
* Never logs VYNDR_INTERNAL_KEY. Headers are constructed inline so the key
|
||||
* never lives in an exported constant that could leak via stack traces.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { getSportConfig } = require('../src/config/sports');
|
||||
const { cacheGet, cacheSet, getRedisClient } = require('../src/utils/redis');
|
||||
const { createLimiter, API_BUDGETS } = require('../src/utils/rateLimiter');
|
||||
const oddsPapiAdapter = require('../src/services/adapters/oddsPapiAdapter');
|
||||
|
||||
const SPORT = (process.env.SPORT || '').toLowerCase();
|
||||
const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL) || 60_000;
|
||||
const BUFFER_MS = Number(process.env.BUFFER_MS) || 30_000;
|
||||
const OFF_HOURS_POLL_MS = 5 * 60_000;
|
||||
const VYNDR_API_URL = process.env.VYNDR_API_URL || 'http://localhost:3001';
|
||||
const NTFY_TOPIC = process.env.NTFY_TOPIC || 'vyndr-admin';
|
||||
const NTFY_PORT = process.env.NTFY_PORT || '8080';
|
||||
|
||||
const STATUS_TTL = 36 * 60 * 60; // 36h, covers doubleheaders
|
||||
const LOCK_TTL = 5 * 60; // 5 min
|
||||
const LIVE_STATUS_TTL = 60 * 60; // 1h
|
||||
const HEARTBEAT_TTL = 180; // 3 min
|
||||
|
||||
const espnLimiter = createLimiter(API_BUDGETS.espn);
|
||||
const mlbLimiter = createLimiter(API_BUDGETS.mlbStats);
|
||||
|
||||
function isFinalStatus(state) {
|
||||
if (!state) return false;
|
||||
const upper = String(state).toUpperCase();
|
||||
return upper.includes('FINAL');
|
||||
}
|
||||
|
||||
function isVoidStatus(state) {
|
||||
const upper = String(state || '').toUpperCase();
|
||||
return upper.includes('POSTPONED') || upper.includes('CANCELED') || upper.includes('CANCELLED');
|
||||
}
|
||||
|
||||
function getETHour() {
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/New_York', hour: 'numeric', hour12: false,
|
||||
});
|
||||
return parseInt(fmt.format(new Date()), 10);
|
||||
}
|
||||
|
||||
function inGameHours(sportCfg) {
|
||||
const h = getETHour();
|
||||
// gameEndHourET can be 25 to represent past-midnight ET — wrap with mod.
|
||||
const start = sportCfg.gameStartHourET;
|
||||
const endRaw = sportCfg.gameEndHourET;
|
||||
if (endRaw >= 24) {
|
||||
return h >= start || h < (endRaw - 24);
|
||||
}
|
||||
return h >= start && h < endRaw;
|
||||
}
|
||||
|
||||
async function ntfyAlert(message) {
|
||||
// Fire-and-forget. Never let alerting failure kill the poller.
|
||||
try {
|
||||
await axios.post(`http://localhost:${NTFY_PORT}/${NTFY_TOPIC}`, message, { timeout: 5_000 });
|
||||
} catch { /* swallow */ }
|
||||
}
|
||||
|
||||
async function fetchEspnScoreboard(sportCfg) {
|
||||
await espnLimiter.waitForToken();
|
||||
const res = await axios.get(sportCfg.espnScoreboard, { timeout: 10_000 });
|
||||
const events = res.data?.events || [];
|
||||
return events.map((ev) => ({
|
||||
id: String(ev.id),
|
||||
state: ev?.status?.type?.state, // 'pre' | 'in' | 'post'
|
||||
name: ev?.status?.type?.name, // STATUS_FINAL, STATUS_IN_PROGRESS, etc.
|
||||
competitions: ev.competitions,
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchEspnBoxScore(sportCfg, gameId) {
|
||||
// The ?event= query param is REQUIRED — without it ESPN returns nothing.
|
||||
await espnLimiter.waitForToken();
|
||||
const res = await axios.get(`${sportCfg.espnSummary}?event=${encodeURIComponent(gameId)}`, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function fetchMlbBoxScore(sportCfg, gamePk) {
|
||||
await mlbLimiter.waitForToken();
|
||||
const res = await axios.get(`${sportCfg.mlbStatsApiBase}/game/${gamePk}/feed/live`, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function extractMlbGamePk(espnEvent) {
|
||||
// ESPN MLB events sometimes carry the MLB Stats API gamePk via a sibling
|
||||
// ID on the competition. Common shapes:
|
||||
// ev.competitions[0].uid "s:1~l:10~e:401472045~c:401472045"
|
||||
// ev.competitions[0].externalIds?.mlb
|
||||
const comp = espnEvent?.competitions?.[0];
|
||||
if (!comp) return null;
|
||||
if (comp.externalIds?.mlb) return String(comp.externalIds.mlb);
|
||||
if (comp.gamePk) return String(comp.gamePk);
|
||||
// Fall back to ESPN event id as a last resort — caller logs if MLB fails.
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateBoxScore(data, sportCfg) {
|
||||
if (!data) return { valid: false, reason: 'no_data' };
|
||||
if (sportCfg.useMlbStatsApi) {
|
||||
const teams = data?.liveData?.boxscore?.teams;
|
||||
if (!teams?.home || !teams?.away) return { valid: false, reason: 'mlb_missing_teams' };
|
||||
return { valid: true };
|
||||
}
|
||||
const players = data?.boxscore?.players;
|
||||
if (!Array.isArray(players) || players.length < 2) {
|
||||
return { valid: false, reason: 'missing_players' };
|
||||
}
|
||||
// For category-based sports (NFL) the inner shape differs from basketball
|
||||
// (statistics array) — both at least exist.
|
||||
if (!players[0]?.statistics) return { valid: false, reason: 'no_statistics' };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
async function postResolution(payload, attempt = 1) {
|
||||
const maxAttempts = 3;
|
||||
try {
|
||||
const res = await axios.post(
|
||||
`${VYNDR_API_URL}/api/grading/resolve`,
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-VYNDR-Internal-Key': process.env.VYNDR_INTERNAL_KEY || '',
|
||||
},
|
||||
timeout: 30_000,
|
||||
validateStatus: (s) => s >= 200 && s < 500,
|
||||
}
|
||||
);
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
console.log(`[poller-${SPORT}] POST /api/grading/resolve → ${res.status} (${res.data?.resolved ?? 0} resolved, ${res.data?.voided ?? 0} voided)`);
|
||||
return res.data;
|
||||
}
|
||||
throw new Error(`status=${res.status}`);
|
||||
} catch (err) {
|
||||
if (attempt < maxAttempts) {
|
||||
console.warn(`[poller-${SPORT}] POST attempt ${attempt} failed: ${err.message}. retrying in 30s.`);
|
||||
await new Promise((r) => setTimeout(r, 30_000));
|
||||
return postResolution(payload, attempt + 1);
|
||||
}
|
||||
await ntfyAlert(`VYNDR poller-${SPORT}: 3x POST /api/grading/resolve failed for game ${payload.gameId}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryAcquireLock(gameId) {
|
||||
// SET NX EX — atomic check-and-set with TTL. ioredis returns 'OK' on win.
|
||||
const redis = getRedisClient();
|
||||
const result = await redis.set(`game:${gameId}:resolution_lock`, '1', 'EX', LOCK_TTL, 'NX');
|
||||
return result === 'OK';
|
||||
}
|
||||
|
||||
async function handleGame(game, sportCfg) {
|
||||
const statusKey = `game:${game.id}:status`;
|
||||
const liveKey = `game:${game.id}:live_status`;
|
||||
const prevStatus = await cacheGet(statusKey);
|
||||
const currentStatus = game.name;
|
||||
|
||||
// Always update live_status for the frontend badges.
|
||||
await cacheSet(liveKey, currentStatus, LIVE_STATUS_TTL);
|
||||
|
||||
// No-op if we've already processed this exact status.
|
||||
if (prevStatus === currentStatus) return;
|
||||
|
||||
if (currentStatus === 'STATUS_IN_PROGRESS' && prevStatus !== 'STATUS_IN_PROGRESS') {
|
||||
console.log(`[poller-${SPORT}] tip-off ${game.id} — triggering OddsPapi capture`);
|
||||
try { await oddsPapiAdapter.batchCapture(SPORT, game.id); }
|
||||
catch (err) { console.warn(`[poller-${SPORT}] OddsPapi capture failed: ${err.message}`); }
|
||||
await cacheSet(statusKey, currentStatus, STATUS_TTL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVoidStatus(currentStatus)) {
|
||||
if (!(await tryAcquireLock(game.id))) return;
|
||||
console.log(`[poller-${SPORT}] ${game.id} → ${currentStatus} (void)`);
|
||||
await postResolution({ gameId: game.id, sport: SPORT, void: true, reason: currentStatus.toLowerCase() });
|
||||
await cacheSet(statusKey, currentStatus, STATUS_TTL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFinalStatus(currentStatus)) {
|
||||
if (!(await tryAcquireLock(game.id))) return;
|
||||
await new Promise((r) => setTimeout(r, BUFFER_MS));
|
||||
let boxScore;
|
||||
try {
|
||||
boxScore = sportCfg.useMlbStatsApi
|
||||
? await fetchMlbBoxScore(sportCfg, extractMlbGamePk(game) || game.id)
|
||||
: await fetchEspnBoxScore(sportCfg, game.id);
|
||||
} catch (err) {
|
||||
console.warn(`[poller-${SPORT}] box-score fetch failed for ${game.id}: ${err.message}`);
|
||||
await ntfyAlert(`VYNDR poller-${SPORT}: box-score fetch failed for game ${game.id}`);
|
||||
return;
|
||||
}
|
||||
const verdict = validateBoxScore(boxScore, sportCfg);
|
||||
if (!verdict.valid) {
|
||||
console.warn(`[poller-${SPORT}] invalid box score for ${game.id}: ${verdict.reason}`);
|
||||
await ntfyAlert(`VYNDR poller-${SPORT}: invalid box score (${verdict.reason}) for game ${game.id}`);
|
||||
return;
|
||||
}
|
||||
await postResolution({ gameId: game.id, sport: SPORT, boxScore });
|
||||
await cacheSet(statusKey, currentStatus, STATUS_TTL);
|
||||
return;
|
||||
}
|
||||
|
||||
// Any other status we just remember so we don't re-print on every tick.
|
||||
await cacheSet(statusKey, currentStatus, STATUS_TTL);
|
||||
}
|
||||
|
||||
async function tick(sportCfg) {
|
||||
await cacheSet(`poller:${SPORT}:heartbeat`, new Date().toISOString(), HEARTBEAT_TTL);
|
||||
let games;
|
||||
try { games = await fetchEspnScoreboard(sportCfg); }
|
||||
catch (err) {
|
||||
console.warn(`[poller-${SPORT}] scoreboard fetch failed: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
for (const g of games) {
|
||||
try { await handleGame(g, sportCfg); }
|
||||
catch (err) { console.warn(`[poller-${SPORT}] game ${g.id} handler error: ${err.message}`); }
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!SPORT) {
|
||||
console.error('SPORT env var is required');
|
||||
process.exit(1);
|
||||
}
|
||||
let sportCfg;
|
||||
try { sportCfg = getSportConfig(SPORT); }
|
||||
catch (err) {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[poller-${SPORT}] starting — pollInterval=${POLL_INTERVAL_MS}ms buffer=${BUFFER_MS}ms`);
|
||||
|
||||
// Run forever. The PM2 supervisor restarts on crash; tick errors are
|
||||
// already caught inside.
|
||||
/* eslint-disable no-constant-condition */
|
||||
while (true) {
|
||||
await tick(sportCfg);
|
||||
const intervalMs = inGameHours(sportCfg) ? POLL_INTERVAL_MS : OFF_HOURS_POLL_MS;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
// Surface for tests — they import individual handlers without firing main().
|
||||
module.exports = {
|
||||
isFinalStatus,
|
||||
isVoidStatus,
|
||||
validateBoxScore,
|
||||
inGameHours,
|
||||
getETHour,
|
||||
extractMlbGamePk,
|
||||
handleGame,
|
||||
postResolution,
|
||||
// Internal — tests may need to clear/inspect state.
|
||||
__testing: { espnLimiter, mlbLimiter },
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
console.error('[poller] fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[build]
|
||||
builder = "NIXPACKS"
|
||||
|
||||
[deploy]
|
||||
startCommand = "cd src/services/python && pip install -r requirements.txt && python app.py"
|
||||
healthcheckPath = "/health"
|
||||
healthcheckTimeout = 30
|
||||
restartPolicyType = "ON_FAILURE"
|
||||
restartPolicyMaxRetries = 3
|
||||
|
||||
[service]
|
||||
internalPort = 5001
|
||||
@@ -13,7 +13,7 @@ const sql = postgres({
|
||||
username: `postgres.${PROJECT_REF}`,
|
||||
password: DB_PASSWORD,
|
||||
ssl: { rejectUnauthorized: false },
|
||||
connection: { application_name: 'betonblk-migration' },
|
||||
connection: { application_name: 'vyndr-migration' },
|
||||
prepare: false,
|
||||
});
|
||||
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# License + security audit. Run from repo root.
|
||||
# Backend deps live in /package.json; the Next.js app has its own under web/.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
#
|
||||
# LGPL-3.0-or-later: appears via sharp's native libvips binary (dynamically
|
||||
# linked, permitted by LGPL).
|
||||
# MPL-2.0: file-level copyleft. Permitted because we don't modify the MPL'd
|
||||
# source files in web-push, lightningcss, etc. — only consume them as
|
||||
# dependencies. If we ever fork an MPL'd file we must release that file.
|
||||
ALLOWED='MIT;MIT-0;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;CC0-1.0;Unlicense;0BSD;Python-2.0;CC-BY-4.0;BlueOak-1.0.0;LGPL-3.0-or-later;MPL-2.0'
|
||||
|
||||
echo "=== Backend npm license audit ==="
|
||||
cd "$ROOT"
|
||||
npx --yes license-checker --production --onlyAllow "$ALLOWED" --excludePackages 'vyndr@1.0.0' || backend_failed=1
|
||||
|
||||
echo ""
|
||||
echo "=== Web npm license audit ==="
|
||||
cd "$ROOT/web"
|
||||
npx --yes license-checker --production --onlyAllow "$ALLOWED" --excludePackages 'vyndr-web@1.0.0' || web_failed=1
|
||||
|
||||
echo ""
|
||||
echo "=== Python license audit (best-effort) ==="
|
||||
if command -v pip-licenses >/dev/null 2>&1; then
|
||||
pip-licenses --format=plain --with-license-file --no-license-path | head -50
|
||||
else
|
||||
echo "(pip-licenses not installed — run: pip install pip-licenses)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Backend security audit ==="
|
||||
cd "$ROOT"
|
||||
npm audit --omit=dev || true
|
||||
|
||||
echo ""
|
||||
echo "=== Web security audit ==="
|
||||
cd "$ROOT/web"
|
||||
npm audit --omit=dev || true
|
||||
|
||||
echo ""
|
||||
if [[ "${backend_failed:-0}" -eq 1 || "${web_failed:-0}" -eq 1 ]]; then
|
||||
echo "License audit FAILED — review packages above."
|
||||
exit 1
|
||||
fi
|
||||
echo "License audit clean."
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Restore-test scaffold. Run by hand against a *test* Supabase project to
|
||||
# confirm a backup is restorable. We intentionally do NOT automate this —
|
||||
# pointing a script at the production DB by mistake would be unrecoverable.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/backup-restore-check.sh <path/to/backup.sql.gz> <test-db-url>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: $0 <path/to/backup.sql.gz> <test-db-url>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
BACKUP="$1"
|
||||
TEST_DB="$2"
|
||||
|
||||
if [[ "$TEST_DB" == *prod* || "$TEST_DB" == *production* ]]; then
|
||||
echo "Refusing to restore: target URL looks like production." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if [[ ! -f "$BACKUP" ]]; then
|
||||
echo "Backup file not found: $BACKUP" >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
echo "Restoring ${BACKUP} → ${TEST_DB}"
|
||||
gunzip -c "$BACKUP" | psql "$TEST_DB"
|
||||
|
||||
echo "Counting tables…"
|
||||
psql "$TEST_DB" -c "select table_schema, count(*) as table_count
|
||||
from information_schema.tables
|
||||
where table_schema in ('public','auth')
|
||||
group by table_schema
|
||||
order by table_schema;"
|
||||
|
||||
echo "Done. Manually verify row counts on key tables before declaring backup healthy."
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# VYNDR — daily PostgreSQL backup.
|
||||
#
|
||||
# Runs from Coolify cron (3am ET). Dumps the Supabase database via the
|
||||
# provided connection string, gzips it, uploads to Cloudflare R2 via
|
||||
# rclone, and prunes local copies older than 7 days. Failures POST to
|
||||
# ntfy so we hear about them within the hour.
|
||||
#
|
||||
# Required environment:
|
||||
# SUPABASE_DB_URL — postgres connection string
|
||||
# NTFY_PORT — ntfy port for alerts (default: 8080)
|
||||
# NTFY_TOPIC — ntfy topic (default: vyndr-admin)
|
||||
# R2_REMOTE — rclone remote name targeting R2 (default: r2)
|
||||
# R2_BUCKET — R2 bucket path (default: vyndr-backups/daily)
|
||||
#
|
||||
# Prerequisites on the host:
|
||||
# pg_dump (PostgreSQL client tools)
|
||||
# gzip
|
||||
# rclone configured with R2 credentials (`rclone config`)
|
||||
# curl (for ntfy)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DATE="$(date -u +%Y-%m-%dT%H%M%SZ)"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/tmp/vyndr-backups}"
|
||||
BACKUP_FILE="${BACKUP_DIR}/vyndr-${DATE}.sql.gz"
|
||||
LOG_FILE="${LOG_FILE:-/var/log/vyndr-backup.log}"
|
||||
NTFY_PORT="${NTFY_PORT:-8080}"
|
||||
NTFY_TOPIC="${NTFY_TOPIC:-vyndr-admin}"
|
||||
R2_REMOTE="${R2_REMOTE:-r2}"
|
||||
R2_BUCKET="${R2_BUCKET:-vyndr-backups/daily}"
|
||||
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-7}"
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
alert() {
|
||||
# Fire-and-forget — never fail the script because the alerter is down.
|
||||
curl -s --max-time 5 -d "$1" "http://localhost:${NTFY_PORT}/${NTFY_TOPIC}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
if [[ -z "${SUPABASE_DB_URL:-}" ]]; then
|
||||
log "ERROR: SUPABASE_DB_URL is not set"
|
||||
alert "VYNDR backup failed: SUPABASE_DB_URL missing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
log "Starting backup → ${BACKUP_FILE}"
|
||||
|
||||
if ! pg_dump --no-owner --no-privileges "$SUPABASE_DB_URL" | gzip > "$BACKUP_FILE"; then
|
||||
log "ERROR: pg_dump failed"
|
||||
alert "VYNDR backup FAILED at ${DATE} — pg_dump"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SIZE="$(du -h "$BACKUP_FILE" | cut -f1)"
|
||||
log "Dump complete: ${SIZE}"
|
||||
|
||||
if ! rclone copy "$BACKUP_FILE" "${R2_REMOTE}:${R2_BUCKET}/"; then
|
||||
log "ERROR: rclone upload failed"
|
||||
alert "VYNDR backup upload FAILED at ${DATE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Upload to ${R2_REMOTE}:${R2_BUCKET} complete"
|
||||
|
||||
# Prune old local copies. R2 retention is configured on the bucket; this
|
||||
# script doesn't try to manage remote retention to avoid accidental deletes.
|
||||
find "$BACKUP_DIR" -name 'vyndr-*.sql.gz' -mtime "+${RETENTION_DAYS}" -delete
|
||||
|
||||
log "Backup ${DATE} complete (${SIZE})"
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Populate player_id_map with ESPN + (where applicable) MLB Stats API IDs.
|
||||
*
|
||||
* node scripts/populate-player-ids.js # all active sports, prompts
|
||||
* node scripts/populate-player-ids.js nba # single sport
|
||||
* node scripts/populate-player-ids.js --dry-run # no DB writes
|
||||
* node scripts/populate-player-ids.js --yes # skip confirmation
|
||||
*
|
||||
* For each sport this script walks ESPN's team list, then each roster, and
|
||||
* upserts every player. MLB additionally name-matches to MLB Stats API for
|
||||
* the mlbam_id (so Statcast lookups can find the player by ID, not name).
|
||||
*
|
||||
* Failure semantics: log + continue. A 4xx on one team doesn't kill the
|
||||
* batch. End-of-run summary prints captured / skipped / errored counts.
|
||||
*/
|
||||
|
||||
if (require.main !== module) {
|
||||
throw new Error('Run directly: node scripts/populate-player-ids.js');
|
||||
}
|
||||
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
|
||||
const axios = require('axios');
|
||||
const readline = require('readline');
|
||||
const { getSupabaseServiceClient } = require('../src/utils/supabase');
|
||||
const { getActiveSports, getSportConfig } = require('../src/config/sports');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const skipConfirm = args.includes('--yes');
|
||||
const explicitSport = args.find((a) => !a.startsWith('--'));
|
||||
|
||||
const ESPN_TEAMS_BASE = 'https://site.api.espn.com/apis/site/v2/sports';
|
||||
const ESPN_THROTTLE_MS = 600;
|
||||
const MLB_PEOPLE_BASE = 'https://statsapi.mlb.com/api/v1/sports/1/players';
|
||||
|
||||
const espnSportPath = {
|
||||
nba: 'basketball/nba',
|
||||
wnba: 'basketball/wnba',
|
||||
ncaab: 'basketball/mens-college-basketball',
|
||||
mlb: 'baseball/mlb',
|
||||
nfl: 'football/nfl',
|
||||
ncaafb: 'football/college-football',
|
||||
nhl: 'hockey/nhl',
|
||||
};
|
||||
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
function normalizeName(name) {
|
||||
if (!name) return '';
|
||||
return name
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '') // strip accents
|
||||
.toLowerCase()
|
||||
.replace(/\b(jr|sr|ii|iii|iv|v)\.?\b/g, '') // suffixes
|
||||
.replace(/[^a-z0-9\s]/g, ' ') // punctuation
|
||||
.replace(/\s+/g, ' ') // collapse spaces
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function fetchJSON(url, { params } = {}) {
|
||||
const res = await axios.get(url, { params, timeout: 15_000 });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function listEspnTeams(sport) {
|
||||
const sub = espnSportPath[sport];
|
||||
if (!sub) throw new Error(`No ESPN path for sport ${sport}`);
|
||||
const data = await fetchJSON(`${ESPN_TEAMS_BASE}/${sub}/teams`);
|
||||
const groups = data?.sports?.[0]?.leagues?.[0]?.teams || [];
|
||||
return groups
|
||||
.map((t) => t?.team)
|
||||
.filter(Boolean)
|
||||
.map((t) => ({ id: t.id, abbreviation: t.abbreviation }));
|
||||
}
|
||||
|
||||
async function fetchEspnRoster(sport, teamId) {
|
||||
const sub = espnSportPath[sport];
|
||||
const data = await fetchJSON(`${ESPN_TEAMS_BASE}/${sub}/teams/${teamId}/roster`);
|
||||
const athletes = [];
|
||||
// Two shapes show up in the wild: a flat athletes[] (most sports), or a
|
||||
// grouped athletes[].items[] (football). Handle both.
|
||||
const top = data?.athletes;
|
||||
if (Array.isArray(top)) {
|
||||
for (const entry of top) {
|
||||
if (entry?.id && entry?.fullName) {
|
||||
athletes.push({ id: String(entry.id), name: entry.fullName });
|
||||
} else if (Array.isArray(entry?.items)) {
|
||||
for (const a of entry.items) {
|
||||
if (a?.id && a?.fullName) athletes.push({ id: String(a.id), name: a.fullName });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return athletes;
|
||||
}
|
||||
|
||||
async function fetchMlbAllPlayers() {
|
||||
const data = await fetchJSON(`${MLB_PEOPLE_BASE}`, { params: { season: new Date().getFullYear() } });
|
||||
const list = data?.people || [];
|
||||
return list.map((p) => ({
|
||||
mlbam_id: String(p.id),
|
||||
fullName: p.fullName,
|
||||
normalized: normalizeName(p.fullName),
|
||||
}));
|
||||
}
|
||||
|
||||
async function processSport(sport, { dryRun }) {
|
||||
// Ensure the sport is one we have a pipeline config for; otherwise the
|
||||
// resolution route would never see this row.
|
||||
try { getSportConfig(sport); }
|
||||
catch { console.warn(`[skip] no SPORT_CONFIG for ${sport}`); return { captured: 0, skipped: 0, errored: 0 }; }
|
||||
|
||||
console.log(`[${sport}] listing ESPN teams…`);
|
||||
const teams = await listEspnTeams(sport);
|
||||
await sleep(ESPN_THROTTLE_MS);
|
||||
|
||||
const allPlayers = [];
|
||||
for (const team of teams) {
|
||||
try {
|
||||
const roster = await fetchEspnRoster(sport, team.id);
|
||||
for (const p of roster) {
|
||||
allPlayers.push({
|
||||
display_name: p.name,
|
||||
normalized_name: normalizeName(p.name),
|
||||
espn_id: p.id,
|
||||
sport,
|
||||
team_abbr: team.abbreviation,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[${sport}] team ${team.abbreviation} roster failed: ${err.message}`);
|
||||
}
|
||||
await sleep(ESPN_THROTTLE_MS);
|
||||
}
|
||||
console.log(`[${sport}] ESPN: ${allPlayers.length} players across ${teams.length} teams`);
|
||||
|
||||
// MLB-only: name-match to MLB Stats API for mlbam_id.
|
||||
if (sport === 'mlb') {
|
||||
try {
|
||||
const mlbList = await fetchMlbAllPlayers();
|
||||
const byName = new Map(mlbList.map((p) => [p.normalized, p.mlbam_id]));
|
||||
let matched = 0;
|
||||
for (const p of allPlayers) {
|
||||
const id = byName.get(p.normalized_name);
|
||||
if (id) { p.mlbam_id = id; matched += 1; }
|
||||
}
|
||||
console.log(`[mlb] matched mlbam_id for ${matched}/${allPlayers.length} players`);
|
||||
} catch (err) {
|
||||
console.warn(`[mlb] mlbam_id matching skipped: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[${sport}] dry-run — would upsert ${allPlayers.length} players`);
|
||||
return { captured: allPlayers.length, skipped: 0, errored: 0, dryRun: true };
|
||||
}
|
||||
|
||||
const supabase = getSupabaseServiceClient();
|
||||
let captured = 0;
|
||||
let errored = 0;
|
||||
// Upsert in batches of 100 to stay friendly with PostgREST request limits.
|
||||
const batchSize = 100;
|
||||
for (let i = 0; i < allPlayers.length; i += batchSize) {
|
||||
const batch = allPlayers.slice(i, i + batchSize).map((p) => ({
|
||||
...p,
|
||||
updated_at: new Date().toISOString(),
|
||||
}));
|
||||
const { error } = await supabase
|
||||
.from('player_id_map')
|
||||
.upsert(batch, { onConflict: 'espn_id' });
|
||||
if (error) {
|
||||
console.warn(`[${sport}] upsert batch ${i / batchSize} failed: ${error.message}`);
|
||||
errored += batch.length;
|
||||
} else {
|
||||
captured += batch.length;
|
||||
}
|
||||
}
|
||||
return { captured, errored, total: allPlayers.length };
|
||||
}
|
||||
|
||||
async function confirm(promptText) {
|
||||
if (skipConfirm) return true;
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((r) => rl.question(promptText, r));
|
||||
rl.close();
|
||||
return /^y(es)?$/i.test(answer.trim());
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const targets = explicitSport ? [explicitSport] : getActiveSports().map((s) => s.key);
|
||||
const target = process.env.SUPABASE_URL || '(unknown)';
|
||||
if (!dryRun) {
|
||||
const ok = await confirm(
|
||||
`This will upsert player IDs into ${target} for ${targets.join(', ')}. Continue? (y/n) `
|
||||
);
|
||||
if (!ok) { console.log('aborted'); process.exit(0); }
|
||||
}
|
||||
|
||||
const summary = {};
|
||||
for (const sport of targets) {
|
||||
try {
|
||||
summary[sport] = await processSport(sport, { dryRun });
|
||||
} catch (err) {
|
||||
console.error(`[${sport}] fatal: ${err.message}`);
|
||||
summary[sport] = { error: err.message };
|
||||
}
|
||||
}
|
||||
console.log('\n=== summary ===');
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Unhandled:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ParlayAPI historical pull.
|
||||
*
|
||||
* BULK operation — runs for hours, not seconds. Free tier is 1,000 credits
|
||||
* per month, so we walk one (sport, date) at a time with a checkpoint in
|
||||
* Redis so an interrupted run can resume right where it stopped.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/pull-parlayapi-history.js nba 2025 # one sport / season
|
||||
* node scripts/pull-parlayapi-history.js all # all active sports
|
||||
* node scripts/pull-parlayapi-history.js --resume # continue from checkpoint
|
||||
* node scripts/pull-parlayapi-history.js --dry-run # don't insert
|
||||
* node scripts/pull-parlayapi-history.js --yes # skip confirmation
|
||||
*
|
||||
* Pace: 2 requests / minute. Set PARLAYAPI_PULL_RATE in env to override.
|
||||
*/
|
||||
|
||||
if (require.main !== module) {
|
||||
throw new Error('Run directly: node scripts/pull-parlayapi-history.js');
|
||||
}
|
||||
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
|
||||
const { getSupabaseServiceClient } = require('../src/utils/supabase');
|
||||
const { getRedisClient } = require('../src/utils/redis');
|
||||
const parlayApi = require('../src/services/adapters/parlayApiAdapter');
|
||||
const { getActiveSports } = require('../src/config/sports');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const resume = args.includes('--resume');
|
||||
const skipConfirm = args.includes('--yes');
|
||||
const positional = args.filter((a) => !a.startsWith('--'));
|
||||
const sportArg = positional[0];
|
||||
const seasonArg = positional[1];
|
||||
|
||||
const RATE_MS = Number(process.env.PARLAYAPI_PULL_RATE_MS) || 30_000; // 2/min by default
|
||||
const SEASON_DEFAULT = new Date().getFullYear();
|
||||
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
async function confirm(question) {
|
||||
if (skipConfirm) return true;
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((r) => rl.question(question, r));
|
||||
rl.close();
|
||||
return /^y(es)?$/i.test(answer.trim());
|
||||
}
|
||||
|
||||
function* dateRange(season) {
|
||||
// Walk a season's worth of days. ParlayAPI's date arg is YYYY-MM-DD.
|
||||
// For each season we cover Aug 1 → Jul 31 of the following calendar
|
||||
// year — that umbrella catches every sport's annual span.
|
||||
const start = new Date(Date.UTC(season, 7, 1));
|
||||
const end = new Date(Date.UTC(season + 1, 6, 31));
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
yield cursor.toISOString().slice(0, 10);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkpointKey(sport, season) {
|
||||
return `parlayapi:checkpoint:${sport}:${season}`;
|
||||
}
|
||||
|
||||
async function readCheckpoint(sport, season) {
|
||||
const redis = getRedisClient();
|
||||
return redis.get(await checkpointKey(sport, season));
|
||||
}
|
||||
|
||||
async function writeCheckpoint(sport, season, date) {
|
||||
const redis = getRedisClient();
|
||||
await redis.set(await checkpointKey(sport, season), date, 'EX', 30 * 24 * 60 * 60);
|
||||
}
|
||||
|
||||
async function processSportSeason(sport, season) {
|
||||
console.log(`[parlayapi-pull] ${sport} ${season} starting (rate=${RATE_MS}ms)`);
|
||||
let totalInserted = 0;
|
||||
let lastSeen = null;
|
||||
if (resume) {
|
||||
lastSeen = await readCheckpoint(sport, season);
|
||||
if (lastSeen) console.log(`[parlayapi-pull] resuming after ${lastSeen}`);
|
||||
}
|
||||
|
||||
for (const date of dateRange(season)) {
|
||||
if (lastSeen && date <= lastSeen) continue;
|
||||
let lines;
|
||||
try {
|
||||
lines = await parlayApi.getClosingLines(sport, date);
|
||||
} catch (err) {
|
||||
console.warn(`[parlayapi-pull] ${date} failed: ${err.message}`);
|
||||
await sleep(RATE_MS);
|
||||
continue;
|
||||
}
|
||||
if (lines.length && !dryRun) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const rows = lines.map((l) => ({
|
||||
sport,
|
||||
game_date: date,
|
||||
player_name: l.player ?? l.player_name ?? 'unknown',
|
||||
stat_type: l.stat_type ?? l.market ?? 'unknown',
|
||||
line: Number(l.line ?? l.point ?? 0),
|
||||
closing_line: Number(l.closing_line ?? l.close ?? null) || null,
|
||||
result: l.result ?? l.outcome ?? null,
|
||||
source: 'parlayapi',
|
||||
}));
|
||||
const { error } = await supabase.from('historical_props').insert(rows);
|
||||
if (error) {
|
||||
console.warn(`[parlayapi-pull] insert failed for ${date}: ${error.message}`);
|
||||
} else {
|
||||
totalInserted += rows.length;
|
||||
}
|
||||
}
|
||||
await writeCheckpoint(sport, season, date);
|
||||
if (dryRun) {
|
||||
console.log(`[parlayapi-pull] dry-run ${date} → ${lines.length} lines`);
|
||||
}
|
||||
await sleep(RATE_MS);
|
||||
}
|
||||
console.log(`[parlayapi-pull] ${sport} ${season} done — inserted ${totalInserted} rows`);
|
||||
return totalInserted;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!parlayApi.configured()) {
|
||||
console.error('PARLAYAPI_KEY is not set');
|
||||
process.exit(2);
|
||||
}
|
||||
const sportList = sportArg && sportArg !== 'all'
|
||||
? [sportArg]
|
||||
: getActiveSports().map((s) => s.key);
|
||||
const season = Number(seasonArg) || SEASON_DEFAULT;
|
||||
|
||||
if (!dryRun) {
|
||||
const ok = await confirm(
|
||||
`This will insert ParlayAPI history for ${sportList.join(', ')} ${season} into ${process.env.SUPABASE_URL || '(unknown)'}. Continue? (y/n) `
|
||||
);
|
||||
if (!ok) { console.log('aborted'); process.exit(0); }
|
||||
}
|
||||
|
||||
for (const sport of sportList) {
|
||||
try { await processSportSeason(sport, season); }
|
||||
catch (err) { console.error(`[parlayapi-pull] ${sport} fatal: ${err.message}`); }
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[parlayapi-pull] fatal:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run all VYNDR migrations (011 onward) against Supabase in order.
|
||||
#
|
||||
# Every statement in the migrations is idempotent (IF NOT EXISTS, CREATE
|
||||
# OR REPLACE, DROP IF EXISTS), so this script is safe to re-run.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/run-migrations.sh # runs against $SUPABASE_DB_URL
|
||||
# scripts/run-migrations.sh --dry-run # prints concatenated SQL to stdout
|
||||
# (paste into Supabase SQL Editor)
|
||||
# scripts/run-migrations.sh --from 015 # only run migrations >= 015
|
||||
#
|
||||
# Requires for live run:
|
||||
# psql (PostgreSQL client) and SUPABASE_DB_URL env var.
|
||||
# Dry run requires only bash + the files in supabase/migrations/.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MIGRATIONS_DIR="$ROOT/supabase/migrations"
|
||||
DRY_RUN=0
|
||||
FROM_PREFIX=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--from) FROM_PREFIX="$2"; shift 2 ;;
|
||||
--help)
|
||||
sed -n '/^# Usage:/,/^# Requires/p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Migrations 001-010 were applied to production long before the VYNDR
|
||||
# rebuild. Default skipping straight to 011. Use --from to override.
|
||||
DEFAULT_FROM="011"
|
||||
FROM="${FROM_PREFIX:-$DEFAULT_FROM}"
|
||||
|
||||
# Collect files >= FROM in numeric order.
|
||||
MIGRATIONS=()
|
||||
while IFS= read -r f; do
|
||||
base="$(basename "$f")"
|
||||
prefix="${base%%_*}"
|
||||
# Bash [[ ]] doesn't have a portable >= for strings; use numeric compare.
|
||||
# Strip leading zeros to avoid octal interpretation.
|
||||
pnum=$((10#$prefix))
|
||||
fnum=$((10#$FROM))
|
||||
if (( pnum >= fnum )); then
|
||||
MIGRATIONS+=("$f")
|
||||
fi
|
||||
done < <(find "$MIGRATIONS_DIR" -maxdepth 1 -type f -name '*.sql' | sort)
|
||||
|
||||
if [[ ${#MIGRATIONS[@]} -eq 0 ]]; then
|
||||
echo "No migrations found at $MIGRATIONS_DIR matching --from $FROM" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Concatenate with header banners so output is auditable.
|
||||
concat_sql() {
|
||||
for f in "${MIGRATIONS[@]}"; do
|
||||
printf -- '-- ============================================================\n'
|
||||
printf -- '-- %s\n' "$(basename "$f")"
|
||||
printf -- '-- ============================================================\n\n'
|
||||
cat "$f"
|
||||
printf '\n\n'
|
||||
done
|
||||
}
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
concat_sql
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "${SUPABASE_DB_URL:-}" ]]; then
|
||||
echo "ERROR: SUPABASE_DB_URL is not set. Re-run with --dry-run to inspect SQL." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "ERROR: psql is not installed. Install postgresql-client or use --dry-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[migrations] applying ${#MIGRATIONS[@]} files to $(echo "$SUPABASE_DB_URL" | sed 's/:[^:@]*@/:***@/')"
|
||||
concat_sql | psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1
|
||||
echo "[migrations] complete"
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sports-Reference scraper — monthly refresh.
|
||||
*
|
||||
* Pulls referee stats and coach career data from Basketball Reference's
|
||||
* public HTML pages. Polite by design:
|
||||
* - 1 request per 5 seconds (well under the rate they tolerate)
|
||||
* - User-Agent identifies us so they can email us if anything's off
|
||||
* - --dry-run flag for safe local experiments
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/scrape-sports-reference.js refs # refresh ref profiles
|
||||
* node scripts/scrape-sports-reference.js coaches # refresh coach profiles
|
||||
* node scripts/scrape-sports-reference.js --dry-run # parse + log, no DB writes
|
||||
* node scripts/scrape-sports-reference.js --yes # skip confirmation prompt
|
||||
*
|
||||
* Sources:
|
||||
* https://www.basketball-reference.com/referees/
|
||||
* https://www.basketball-reference.com/coaches/
|
||||
*
|
||||
* If network access from your host is blocked, this script accepts a saved
|
||||
* HTML fixture via REF_HTML_FILE or COACH_HTML_FILE env vars (used by the
|
||||
* unit test that ships with this codebase).
|
||||
*/
|
||||
|
||||
if (require.main !== module) {
|
||||
throw new Error('Run directly: node scripts/scrape-sports-reference.js');
|
||||
}
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
const axios = require('axios');
|
||||
const cheerio = require('cheerio');
|
||||
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
|
||||
const { getSupabaseServiceClient } = require('../src/utils/supabase');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const skipConfirm = args.includes('--yes');
|
||||
const target = args.find((a) => !a.startsWith('--')) || 'refs';
|
||||
|
||||
const USER_AGENT = 'VYNDR Research Bot (contact@vyndr.app)';
|
||||
const THROTTLE_MS = 5_000;
|
||||
const HTTP_TIMEOUT_MS = 20_000;
|
||||
|
||||
const REF_INDEX_URL = 'https://www.basketball-reference.com/referees/';
|
||||
const COACH_INDEX_URL = 'https://www.basketball-reference.com/coaches/';
|
||||
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
async function fetchPage(url) {
|
||||
const fileOverride = process.env[url === REF_INDEX_URL ? 'REF_HTML_FILE' : 'COACH_HTML_FILE'];
|
||||
if (fileOverride) {
|
||||
return fs.readFileSync(fileOverride, 'utf8');
|
||||
}
|
||||
const res = await axios.get(url, {
|
||||
headers: { 'User-Agent': USER_AGENT, Accept: 'text/html' },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// Basketball-Reference tables follow a stable structure: <table id="..."> with
|
||||
// <thead>/<tbody>/<tr>. Each row's cells are <th> or <td> with data-stat
|
||||
// attributes — that's our primary parser key.
|
||||
function parseTable($, tableSelector) {
|
||||
const rows = [];
|
||||
$(`${tableSelector} tbody tr`).each((_, tr) => {
|
||||
const $tr = $(tr);
|
||||
if ($tr.hasClass('thead') || $tr.hasClass('rowSep')) return;
|
||||
const row = {};
|
||||
$tr.find('th, td').each((__, cell) => {
|
||||
const $cell = $(cell);
|
||||
const key = $cell.attr('data-stat');
|
||||
if (!key) return;
|
||||
row[key] = $cell.text().trim();
|
||||
});
|
||||
if (Object.keys(row).length) rows.push(row);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function num(v) {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function parseRefRows(rows) {
|
||||
// Expected data-stat keys: ref, g (games), fouls_per_g, ft_per_g, …
|
||||
return rows.map((r) => ({
|
||||
ref_name: r.ref ?? r.player ?? r.name ?? null,
|
||||
games_reffed: num(r.g ?? r.games),
|
||||
avg_fouls_per_game: num(r.fouls_per_g ?? r.fouls_per_game),
|
||||
avg_free_throws_per_game: num(r.ft_per_g ?? r.ft_per_game),
|
||||
// pace_impact and home_whistle_bias are NOT directly in BR. They get
|
||||
// computed downstream by a follow-up SQL view over historical games.
|
||||
// Leaving these null on initial scrape is intentional.
|
||||
pace_impact: null,
|
||||
home_whistle_bias: null,
|
||||
})).filter((r) => r.ref_name);
|
||||
}
|
||||
|
||||
function parseCoachRows(rows) {
|
||||
return rows.map((r) => ({
|
||||
coach_name: r.coach ?? r.coaches ?? r.name ?? null,
|
||||
career_avg_pace: num(r.pace ?? r.pace_p100),
|
||||
tenure_games: num(r.g),
|
||||
// The team / current_team_pace / primary_player columns get added by a
|
||||
// separate enrichment pass; BR's coaches index only carries career totals.
|
||||
})).filter((r) => r.coach_name);
|
||||
}
|
||||
|
||||
async function scrapeRefs() {
|
||||
const html = await fetchPage(REF_INDEX_URL);
|
||||
const $ = cheerio.load(html);
|
||||
const rows = parseTable($, 'table#refs') ;
|
||||
const profiles = parseRefRows(rows);
|
||||
return profiles;
|
||||
}
|
||||
|
||||
async function scrapeCoaches() {
|
||||
const html = await fetchPage(COACH_INDEX_URL);
|
||||
const $ = cheerio.load(html);
|
||||
const rows = parseTable($, 'table#coaches');
|
||||
return parseCoachRows(rows);
|
||||
}
|
||||
|
||||
async function upsertRefs(profiles) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const stamp = new Date().toISOString();
|
||||
let captured = 0;
|
||||
let errored = 0;
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < profiles.length; i += batchSize) {
|
||||
const batch = profiles.slice(i, i + batchSize).map((p) => ({ ...p, last_updated: stamp }));
|
||||
const { error } = await supabase
|
||||
.from('ref_profiles')
|
||||
.upsert(batch, { onConflict: 'ref_name' });
|
||||
if (error) {
|
||||
console.warn(`[scraper] refs batch ${i / batchSize} failed: ${error.message}`);
|
||||
errored += batch.length;
|
||||
} else {
|
||||
captured += batch.length;
|
||||
}
|
||||
}
|
||||
return { captured, errored };
|
||||
}
|
||||
|
||||
async function upsertCoaches(profiles) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const stamp = new Date().toISOString();
|
||||
let captured = 0;
|
||||
let errored = 0;
|
||||
// Coaches need (coach_name, team, sport) to match the unique index — but
|
||||
// BR's index page doesn't have those columns. We write what we have and
|
||||
// leave the team/sport columns to be filled by manual or follow-up
|
||||
// enrichment.
|
||||
for (const p of profiles) {
|
||||
const row = {
|
||||
coach_name: p.coach_name,
|
||||
team: 'UNK',
|
||||
sport: 'nba',
|
||||
career_avg_pace: p.career_avg_pace,
|
||||
tenure_games: p.tenure_games || 0,
|
||||
last_updated: stamp,
|
||||
};
|
||||
const { error } = await supabase
|
||||
.from('coach_profiles')
|
||||
.upsert(row, { onConflict: 'coach_name,team,sport' });
|
||||
if (error) {
|
||||
console.warn(`[scraper] coach upsert failed for ${p.coach_name}: ${error.message}`);
|
||||
errored += 1;
|
||||
} else {
|
||||
captured += 1;
|
||||
}
|
||||
await sleep(50); // gentle DB pacing
|
||||
}
|
||||
return { captured, errored };
|
||||
}
|
||||
|
||||
async function confirm(question) {
|
||||
if (skipConfirm) return true;
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((r) => rl.question(question, r));
|
||||
rl.close();
|
||||
return /^y(es)?$/i.test(answer.trim());
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (target !== 'refs' && target !== 'coaches') {
|
||||
console.error('Usage: scrape-sports-reference.js refs|coaches [--dry-run] [--yes]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
const ok = await confirm(`This will upsert ${target} profiles into ${process.env.SUPABASE_URL || '(unknown)'}. Continue? (y/n) `);
|
||||
if (!ok) { console.log('aborted'); process.exit(0); }
|
||||
}
|
||||
|
||||
await sleep(THROTTLE_MS);
|
||||
|
||||
if (target === 'refs') {
|
||||
const profiles = await scrapeRefs();
|
||||
console.log(`[scraper] parsed ${profiles.length} ref profiles`);
|
||||
if (dryRun) { console.log(JSON.stringify(profiles.slice(0, 5), null, 2)); return; }
|
||||
const summary = await upsertRefs(profiles);
|
||||
console.log('[scraper] refs upsert summary:', summary);
|
||||
} else {
|
||||
const profiles = await scrapeCoaches();
|
||||
console.log(`[scraper] parsed ${profiles.length} coach profiles`);
|
||||
if (dryRun) { console.log(JSON.stringify(profiles.slice(0, 5), null, 2)); return; }
|
||||
const summary = await upsertCoaches(profiles);
|
||||
console.log('[scraper] coaches upsert summary:', summary);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[scraper] fatal:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = { parseTable, parseRefRows, parseCoachRows };
|
||||
@@ -0,0 +1,67 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PROGRESS_FILE = path.join(__dirname, 'seed_progress.json');
|
||||
|
||||
const NBA_TEAMS = [
|
||||
'ATL', 'BOS', 'BKN', 'CHA', 'CHI', 'CLE', 'DAL', 'DEN',
|
||||
'DET', 'GSW', 'HOU', 'IND', 'LAC', 'LAL', 'MEM', 'MIA',
|
||||
'MIL', 'MIN', 'NOP', 'NYK', 'OKC', 'ORL', 'PHI', 'PHX',
|
||||
'POR', 'SAC', 'SAS', 'TOR', 'UTA', 'WAS',
|
||||
];
|
||||
|
||||
function loadProgress() {
|
||||
if (fs.existsSync(PROGRESS_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(PROGRESS_FILE, 'utf-8'));
|
||||
}
|
||||
return { completed_teams: [], last_team: null, started_at: new Date().toISOString() };
|
||||
}
|
||||
|
||||
function saveProgress(progress) {
|
||||
fs.writeFileSync(PROGRESS_FILE, JSON.stringify(progress, null, 2));
|
||||
}
|
||||
|
||||
async function seedTeam(team, supabase) {
|
||||
console.log('[seed] Processing ' + team + '...');
|
||||
// Placeholder: In production, fetch game logs from NBA API
|
||||
// and calculate role profiles using roleProfileEngine
|
||||
console.log('[seed] ' + team + ' — role profiles calculated and stored.');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
const { getSupabaseServiceClient } = require('../src/utils/supabase');
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
const progress = loadProgress();
|
||||
console.log('[seed] Starting. ' + progress.completed_teams.length + ' teams already done.');
|
||||
|
||||
for (const team of NBA_TEAMS) {
|
||||
if (progress.completed_teams.includes(team)) {
|
||||
console.log('[seed] Skipping ' + team + ' (already done)');
|
||||
continue;
|
||||
}
|
||||
|
||||
progress.last_team = team;
|
||||
saveProgress(progress);
|
||||
|
||||
try {
|
||||
await seedTeam(team, supabase);
|
||||
progress.completed_teams.push(team);
|
||||
saveProgress(progress);
|
||||
} catch (err) {
|
||||
console.error('[seed] ERROR on ' + team + ':', err.message);
|
||||
console.log('[seed] Progress saved. Re-run to resume.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[seed] All ' + NBA_TEAMS.length + ' teams complete!');
|
||||
progress.completed_at = new Date().toISOString();
|
||||
saveProgress(progress);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[seed] Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
seed_historical.py — One-time historical data seeder for VYNDR.
|
||||
Run ONCE before launch to backfill coaching and player-out data.
|
||||
|
||||
Usage:
|
||||
python scripts/seed_historical.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
|
||||
# Allow imports from src/services/python
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src', 'services', 'python'))
|
||||
|
||||
from coaching_parser import parse_nba_coaching_from_game_id, parse_mlb_coaching_from_game_id
|
||||
from player_outs import find_and_log_historical_player_outs
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def seed_nba_coaching(season='2024-25'):
|
||||
"""Seed NBA coaching data from LeagueGameLog for a full season."""
|
||||
from nba_api.stats.endpoints import LeagueGameLog
|
||||
|
||||
logger.info(f"Fetching NBA game log for season {season}...")
|
||||
game_log = LeagueGameLog(season=season, season_type_all_star='Regular Season')
|
||||
df = game_log.get_data_frames()[0]
|
||||
|
||||
game_ids = df['GAME_ID'].unique()
|
||||
total = len(game_ids)
|
||||
logger.info(f"Found {total} unique NBA games to process.")
|
||||
|
||||
for i, game_id in enumerate(game_ids, start=1):
|
||||
try:
|
||||
parse_nba_coaching_from_game_id(game_id)
|
||||
except Exception as e:
|
||||
logger.error(f"NBA game {game_id} failed: {e}")
|
||||
|
||||
if i % 50 == 0:
|
||||
logger.info(f"NBA coaching progress: {i}/{total} games processed")
|
||||
|
||||
time.sleep(0.6)
|
||||
|
||||
logger.info(f"NBA coaching seed complete. {total} games processed.")
|
||||
|
||||
|
||||
def seed_mlb_coaching(season=2024):
|
||||
"""Seed MLB coaching data from statsapi schedule for a full season."""
|
||||
import statsapi
|
||||
|
||||
start_date = f'{season}-03-28'
|
||||
end_date = f'{season}-09-29'
|
||||
|
||||
logger.info(f"Fetching MLB schedule for {start_date} to {end_date}...")
|
||||
schedule = statsapi.schedule(start_date=start_date, end_date=end_date)
|
||||
|
||||
game_ids = [g['game_id'] for g in schedule]
|
||||
total = len(game_ids)
|
||||
logger.info(f"Found {total} MLB games to process.")
|
||||
|
||||
for i, game_id in enumerate(game_ids, start=1):
|
||||
try:
|
||||
parse_mlb_coaching_from_game_id(game_id)
|
||||
except Exception as e:
|
||||
logger.error(f"MLB game {game_id} failed: {e}")
|
||||
|
||||
if i % 100 == 0:
|
||||
logger.info(f"MLB coaching progress: {i}/{total} games processed")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
logger.info(f"MLB coaching seed complete. {total} games processed.")
|
||||
|
||||
|
||||
def seed_player_out_history(season='2024-25'):
|
||||
"""Seed historical player-out data for a given season."""
|
||||
logger.info(f"Seeding player-out history for season {season}...")
|
||||
try:
|
||||
find_and_log_historical_player_outs(season=season)
|
||||
logger.info("Player-out history seed complete.")
|
||||
except Exception as e:
|
||||
logger.error(f"Player-out history seed failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info("=== VYNDR Historical Data Seeder ===")
|
||||
logger.info("This should be run ONCE before launch.\n")
|
||||
|
||||
logger.info("--- Step 1/3: NBA Coaching ---")
|
||||
seed_nba_coaching(season='2024-25')
|
||||
|
||||
logger.info("--- Step 2/3: MLB Coaching ---")
|
||||
seed_mlb_coaching(season=2024)
|
||||
|
||||
logger.info("--- Step 3/3: Player-Out History ---")
|
||||
seed_player_out_history(season='2024-25')
|
||||
|
||||
logger.info("=== All historical seeds complete. ===")
|
||||
+6
-6
@@ -1,13 +1,13 @@
|
||||
#!/bin/bash
|
||||
# Start both BetonBLK services
|
||||
# Start both VYNDR services
|
||||
# Usage: ./scripts/start.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "[BetonBLK] Starting services..."
|
||||
echo "[VYNDR] Starting services..."
|
||||
|
||||
# Start Python NBA stats service (port 8000)
|
||||
echo "[BetonBLK] Starting NBA stats service on port 8000..."
|
||||
echo "[VYNDR] Starting NBA stats service on port 8000..."
|
||||
cd nba-service
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 &
|
||||
@@ -15,16 +15,16 @@ NBA_PID=$!
|
||||
cd ..
|
||||
|
||||
# Start Node.js API server (port 3000)
|
||||
echo "[BetonBLK] Starting Node API server on port 3000..."
|
||||
echo "[VYNDR] Starting Node API server on port 3000..."
|
||||
node src/server.js &
|
||||
NODE_PID=$!
|
||||
|
||||
echo "[BetonBLK] Services running:"
|
||||
echo "[VYNDR] Services running:"
|
||||
echo " Node API: http://localhost:3000 (PID: $NODE_PID)"
|
||||
echo " NBA Stats: http://localhost:8000 (PID: $NBA_PID)"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop all services."
|
||||
|
||||
trap "kill $NBA_PID $NODE_PID 2>/dev/null; echo '[BetonBLK] Services stopped.'" EXIT
|
||||
trap "kill $NBA_PID $NODE_PID 2>/dev/null; echo '[VYNDR] Services stopped.'" EXIT
|
||||
|
||||
wait
|
||||
|
||||
@@ -9,7 +9,7 @@ const supabase = createClient(
|
||||
const EXPECTED_TABLES = ['users', 'picks', 'scan_sessions', 'bets', 'outcomes', 'performance'];
|
||||
|
||||
async function verifySchema() {
|
||||
console.log('[BetonBLK] Verifying database schema...\n');
|
||||
console.log('[VYNDR] Verifying database schema...\n');
|
||||
|
||||
let allPassed = true;
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ Map Odds API market keys to our internal names:
|
||||
- **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 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Feature 1.3 — Prop Analysis Engine
|
||||
|
||||
## Overview
|
||||
The core intelligence of BetonBLK. Takes a player prop (player, stat, line, book) and runs a 6-step grading pipeline to produce a grade (A/B/C/D), edge percentage, confidence score, kill condition flags, and natural-language reasoning. This is the engine that powers the parlay scanner (Feature 2.1) and drives all user-facing analysis.
|
||||
The core intelligence of VYNDR. Takes a player prop (player, stat, line, book) and runs a 6-step grading pipeline to produce a grade (A/B/C/D), edge percentage, confidence score, kill condition flags, and natural-language reasoning. This is the engine that powers the parlay scanner (Feature 2.1) and drives all user-facing analysis.
|
||||
|
||||
## Dependencies
|
||||
- Feature 1.1 — Odds API Integration (provides lines from multiple books)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Feature 1.4 — Database Schema (Supabase + RLS)
|
||||
|
||||
## Overview
|
||||
Complete PostgreSQL schema in Supabase for all BetonBLK data. Uses Supabase Auth for user identity. Row Level Security (RLS) on all tables ensures users can only access their own data. Service role key used by backend for admin operations.
|
||||
Complete PostgreSQL schema in Supabase for all VYNDR data. Uses Supabase Auth for user identity. Row Level Security (RLS) on all tables ensures users can only access their own data. Service role key used by backend for admin operations.
|
||||
|
||||
## Dependencies
|
||||
- None (builds parallel with Features 1.1, 1.2)
|
||||
|
||||
@@ -233,7 +233,7 @@ Period boundaries:
|
||||
When a user submits a bet with `scan_session_id`:
|
||||
- Validate the session exists and belongs to the user
|
||||
- Store the reference in `bets.slip_data.scan_session_id`
|
||||
- This enables "how many bets followed BetonBLK grades" analytics later
|
||||
- This enables "how many bets followed VYNDR grades" analytics later
|
||||
|
||||
## Service Architecture
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Feature 2.1 — Parlay Scan
|
||||
|
||||
## Overview
|
||||
The flagship user-facing feature. A user submits a parlay (array of prop legs), BetonBLK grades each leg individually, checks for correlations and conflicts between legs, produces an overall parlay grade, and writes the scan to the database. Free users get 5 scans per month. At scan 5, the system fires a personalized upgrade pitch based on what it learned from scans 1-4.
|
||||
The flagship user-facing feature. A user submits a parlay (array of prop legs), VYNDR grades each leg individually, checks for correlations and conflicts between legs, produces an overall parlay grade, and writes the scan to the database. Free users get 5 scans per month. At scan 5, the system fires a personalized upgrade pitch based on what it learned from scans 1-4.
|
||||
|
||||
## Dependencies
|
||||
- Feature 1.3 — Prop Analysis Engine (`POST /api/analyze/batch`)
|
||||
@@ -208,7 +208,7 @@ hook: "You've scanned {total} parlays this month. {good_count} graded B or highe
|
||||
compliment options:
|
||||
- "you've got a good eye" (if good_count >= 3)
|
||||
- "you're getting sharper" (if good_count >= 2)
|
||||
- "BetonBLK is helping you filter" (if good_count >= 1)
|
||||
- "VYNDR is helping you filter" (if good_count >= 1)
|
||||
- "let's find better edges together" (if good_count == 0)
|
||||
|
||||
insight: "Your best edge has been {top_stat_type} {top_direction}s. {tier_benefit}."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Feature 3.1 — Landing Page
|
||||
|
||||
## Overview
|
||||
Next.js marketing site. Hero section, How It Works, 3-tier pricing with founder badges, email capture CTA, and a blog for SEO content. Deployed on Vercel. This is the first thing a visitor sees — it needs to convert. All copy uses BetonBLK voice: direct, confident, no fluff, speaks like a sharp bettor who respects your time.
|
||||
Next.js marketing site. Hero section, How It Works, 3-tier pricing with founder badges, email capture CTA, and a blog for SEO content. Deployed on Vercel. This is the first thing a visitor sees — it needs to convert. All copy uses VYNDR voice: direct, confident, no fluff, speaks like a sharp bettor who respects your time.
|
||||
|
||||
## Dependencies
|
||||
- None (static marketing page, no backend calls)
|
||||
@@ -11,7 +11,7 @@ Next.js marketing site. Hero section, How It Works, 3-tier pricing with founder
|
||||
- **Framework:** Next.js 14+ (App Router)
|
||||
- **Styling:** Tailwind CSS
|
||||
- **Deployment:** Vercel
|
||||
- **Directory:** `web/` in the betonblk repo (monorepo)
|
||||
- **Directory:** `web/` in the vyndr repo (monorepo)
|
||||
|
||||
## Pages
|
||||
|
||||
@@ -20,7 +20,7 @@ Single-page scroll with sections:
|
||||
|
||||
**1. Hero**
|
||||
- Headline: "Stop guessing. Start grading."
|
||||
- Subheadline: "BetonBLK scans your parlay in seconds. AI-powered prop analysis across DraftKings, FanDuel, and BetMGM."
|
||||
- Subheadline: "VYNDR scans your parlay in seconds. AI-powered prop analysis across DraftKings, FanDuel, and BetMGM."
|
||||
- CTA button: "Scan Your First Parlay — Free" → links to /scan (Feature 3.2)
|
||||
- Background: dark, clean, sports-betting aesthetic. No stock photos.
|
||||
|
||||
@@ -109,7 +109,7 @@ web/
|
||||
│ └── pra-props-explained.mdx
|
||||
```
|
||||
|
||||
## BetonBLK Voice Guide
|
||||
## VYNDR Voice Guide
|
||||
All user-facing copy follows these rules:
|
||||
- **Direct.** No hedging. "This line is soft" not "This line might be worth considering."
|
||||
- **Confident.** The system did the work. Present findings with authority.
|
||||
@@ -176,7 +176,7 @@ web/
|
||||
7. Blog index at /blog lists posts sorted by date
|
||||
8. Individual blog posts render MDX with correct formatting
|
||||
9. Blog posts include Open Graph tags and JSON-LD structured data
|
||||
10. All copy follows BetonBLK voice (direct, confident, concise)
|
||||
10. All copy follows VYNDR voice (direct, confident, concise)
|
||||
11. Responsive: works on mobile, tablet, desktop
|
||||
12. Deploys to Vercel
|
||||
13. Lighthouse performance score > 90
|
||||
|
||||
@@ -12,7 +12,7 @@ The parlay scanner interface. Users input legs via a manual form builder, submit
|
||||
### Layout
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Header: BetonBLK logo + nav + scan counter │
|
||||
│ Header: VYNDR logo + nav + scan counter │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─── Leg Builder ────────────────────────┐ │
|
||||
|
||||
@@ -12,7 +12,7 @@ Dashboard for tracking bets, viewing performance, and discovering behavioral pat
|
||||
### Layout
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Header: BetonBLK logo + nav │
|
||||
│ Header: VYNDR logo + nav │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─ Performance Cards ─────────────────────┐ │
|
||||
|
||||
@@ -98,7 +98,7 @@ Simple validation — not a full promo code engine:
|
||||
VALID_FOUNDER_CODES = ['FOUNDER2026', 'BETONBLK', 'EARLYBIRD']
|
||||
```
|
||||
|
||||
Stored in environment variable: `FOUNDER_CODES=FOUNDER2026,BETONBLK,EARLYBIRD`
|
||||
Stored in environment variable: `FOUNDER_CODES=FOUNDER2026,VYNDR,EARLYBIRD`
|
||||
|
||||
When a valid founder code is used:
|
||||
1. Checkout uses the founder price ID
|
||||
@@ -126,9 +126,9 @@ src/
|
||||
```
|
||||
STRIPE_SECRET_KEY=sk_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
FOUNDER_CODES=FOUNDER2026,BETONBLK,EARLYBIRD
|
||||
FOUNDER_CODES=FOUNDER2026,VYNDR,EARLYBIRD
|
||||
FOUNDER_CODE_EXPIRY=2026-06-30
|
||||
BASE_URL=https://betonblk.com
|
||||
BASE_URL=https://vyndr.app
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
+104
@@ -1,5 +1,6 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const oddsRoutes = require('./routes/odds');
|
||||
const analyzeRoutes = require('./routes/analyze');
|
||||
const scanRoutes = require('./routes/scan');
|
||||
@@ -7,13 +8,104 @@ const movementsRoutes = require('./routes/movements');
|
||||
const alertsRoutes = require('./routes/alerts');
|
||||
const betsRoutes = require('./routes/bets');
|
||||
const stripeRoutes = require('./routes/stripe');
|
||||
const statsRoutes = require('./routes/stats');
|
||||
const propsRoutes = require('./routes/props');
|
||||
const waitlistRoutes = require('./routes/waitlist');
|
||||
const pipelineRoutes = require('./routes/pipeline');
|
||||
const shareCardRoutes = require('./routes/shareCard');
|
||||
const pushRoutes = require('./routes/push');
|
||||
const gradingRoutes = require('./routes/grading');
|
||||
const correctionRoutes = require('./routes/corrections');
|
||||
const { missionHeader } = require('./middleware/mission');
|
||||
|
||||
const app = express();
|
||||
|
||||
// CORS — accept the Next.js frontend on Vercel/production and localhost dev.
|
||||
// FRONTEND_ORIGINS overrides at deploy time (comma-separated).
|
||||
const defaultOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'https://vyndr.app',
|
||||
'https://www.vyndr.app',
|
||||
];
|
||||
const envOrigins = (process.env.FRONTEND_ORIGINS || '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const allowedOrigins = [...new Set([...defaultOrigins, ...envOrigins])];
|
||||
app.use(
|
||||
cors({
|
||||
origin(origin, cb) {
|
||||
// Allow same-origin (no Origin header) and the configured allowlist.
|
||||
// Also allow any *.vercel.app preview for staging.
|
||||
if (!origin) return cb(null, true);
|
||||
if (allowedOrigins.includes(origin)) return cb(null, true);
|
||||
if (/\.vercel\.app$/.test(new URL(origin).hostname)) return cb(null, true);
|
||||
return cb(new Error(`Origin ${origin} not allowed`));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
})
|
||||
);
|
||||
|
||||
// Mission header on all responses
|
||||
app.use(missionHeader);
|
||||
|
||||
// Stripe webhook needs raw body — must be before express.json()
|
||||
app.use('/api/stripe/webhook', express.raw({ type: 'application/json' }));
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Health check — public minimal status (Coolify, uptime monitors). Detailed
|
||||
// adapter + Python service status only with X-VYNDR-Internal-Key.
|
||||
app.get('/api/health', async (req, res) => {
|
||||
const checks = {};
|
||||
|
||||
try {
|
||||
const { getRedisClient, isDegraded } = require('./utils/redis');
|
||||
if (isDegraded()) throw new Error('degraded');
|
||||
await getRedisClient().ping();
|
||||
checks.redis = 'ok';
|
||||
} catch { checks.redis = 'down'; }
|
||||
|
||||
try {
|
||||
const { getSupabaseServiceClient } = require('./utils/supabase');
|
||||
const { error } = await getSupabaseServiceClient().from('users').select('id').limit(1);
|
||||
checks.supabase = error ? 'error' : 'ok';
|
||||
} catch { checks.supabase = 'down'; }
|
||||
|
||||
const healthy = checks.redis === 'ok' && checks.supabase === 'ok';
|
||||
const expectedKey = process.env.VYNDR_INTERNAL_KEY;
|
||||
const providedKey = req.headers['x-vyndr-internal-key'];
|
||||
|
||||
if (expectedKey && providedKey === expectedKey) {
|
||||
try {
|
||||
const axios = require('axios');
|
||||
const pyUrl = process.env.PYTHON_SERVICE_URL || 'http://localhost:8000';
|
||||
await axios.get(`${pyUrl}/health`, { timeout: 3_000 });
|
||||
checks.python = 'ok';
|
||||
} catch { checks.python = 'down'; }
|
||||
|
||||
checks.adapters = {
|
||||
sharpapi: require('./services/adapters/sharpApiAdapter').configured(),
|
||||
propodds: require('./services/adapters/propOddsAdapter').configured(),
|
||||
parlayapi: require('./services/adapters/parlayApiAdapter').configured(),
|
||||
oddspapi: require('./services/adapters/oddsPapiAdapter').configured(),
|
||||
cfbd: require('./services/adapters/cfbdAdapter').configured(),
|
||||
openrouter: require('./services/adapters/openRouterAdapter').configured(),
|
||||
};
|
||||
checks.engine2_enabled = process.env.ENGINE2_ENABLED === 'true';
|
||||
|
||||
return res.status(healthy ? 200 : 503).json({
|
||||
status: healthy ? 'healthy' : 'degraded',
|
||||
checks,
|
||||
version: require('../package.json').version || '1.0.0',
|
||||
uptime: Math.floor(process.uptime()),
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(healthy ? 200 : 503).json({
|
||||
status: healthy ? 'healthy' : 'degraded',
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/odds', oddsRoutes);
|
||||
app.use('/api/analyze', analyzeRoutes);
|
||||
app.use('/api/scan', scanRoutes);
|
||||
@@ -21,5 +113,17 @@ app.use('/api/movements', movementsRoutes);
|
||||
app.use('/api/alerts', alertsRoutes);
|
||||
app.use('/api/bets', betsRoutes);
|
||||
app.use('/api/stripe', stripeRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
app.use('/api/props', propsRoutes);
|
||||
app.use('/api/waitlist', waitlistRoutes);
|
||||
app.use('/api/pipeline', pipelineRoutes);
|
||||
app.use('/api/share-card', shareCardRoutes);
|
||||
app.use('/api/push', pushRoutes);
|
||||
// Resolution payloads carry full ESPN box scores (50-100KB). Scope a larger
|
||||
// limit to /api/grading only so the other routes keep the safer 100KB default.
|
||||
app.use('/api/grading', express.json({ limit: '2mb' }), gradingRoutes);
|
||||
app.use('/api/grading', express.json({ limit: '256kb' }), correctionRoutes);
|
||||
const widgetRoutes = require('./routes/widget');
|
||||
app.use('/api/widget', widgetRoutes);
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"_meta": {
|
||||
"purpose": "Seed coach profiles. Loaded into coach_profiles on first cold-start. Update when a coaching change happens, at season start, and at the trade deadline.",
|
||||
"fields": "coach_name, team, sport, career_avg_pace, current_team_pace, tenure_games, primary_player, system_style, without_primary_style, without_primary_pace_delta",
|
||||
"kev_action": "Populate with researched values per coach. The structure below has the canonical starters as placeholders so the cold-path doesn't crash on empty tables."
|
||||
},
|
||||
"coaches": [
|
||||
{
|
||||
"coach_name": "Tom Thibodeau",
|
||||
"team": "NYK",
|
||||
"sport": "nba",
|
||||
"career_avg_pace": 96.5,
|
||||
"current_team_pace": 98.1,
|
||||
"tenure_games": 320,
|
||||
"primary_player": "Jalen Brunson",
|
||||
"system_style": "half_court_iso",
|
||||
"without_primary_style": "motion",
|
||||
"without_primary_pace_delta": 1.5
|
||||
},
|
||||
{
|
||||
"coach_name": "Joe Mazzulla",
|
||||
"team": "BOS",
|
||||
"sport": "nba",
|
||||
"career_avg_pace": 99.5,
|
||||
"current_team_pace": 100.1,
|
||||
"tenure_games": 180,
|
||||
"primary_player": "Jayson Tatum",
|
||||
"system_style": "motion",
|
||||
"without_primary_style": "transition",
|
||||
"without_primary_pace_delta": 2.0
|
||||
},
|
||||
{
|
||||
"coach_name": "Erik Spoelstra",
|
||||
"team": "MIA",
|
||||
"sport": "nba",
|
||||
"career_avg_pace": 95.8,
|
||||
"current_team_pace": 96.0,
|
||||
"tenure_games": 1200,
|
||||
"primary_player": "Jimmy Butler",
|
||||
"system_style": "half_court_iso",
|
||||
"without_primary_style": "motion",
|
||||
"without_primary_pace_delta": 1.2
|
||||
},
|
||||
{
|
||||
"coach_name": "Sandy Brondello",
|
||||
"team": "NYL",
|
||||
"sport": "wnba",
|
||||
"career_avg_pace": 80.5,
|
||||
"current_team_pace": 81.2,
|
||||
"tenure_games": 110,
|
||||
"primary_player": "Breanna Stewart",
|
||||
"system_style": "motion",
|
||||
"without_primary_style": "motion",
|
||||
"without_primary_pace_delta": 0.5
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Sport configuration — two layers in one file.
|
||||
*
|
||||
* SPORTS (legacy, UI-facing)
|
||||
* `active`, `collectData`, `comingSoon` — drives the landing-page badges
|
||||
* and the "is grading on for this sport" gate. Consumed by:
|
||||
* - src/services/UnifiedOddsProvider.js (shouldCollect)
|
||||
* - src/routes/pipeline.js (isActiveSport)
|
||||
* Mirror in `web/src/config/sports.ts`.
|
||||
*
|
||||
* SPORT_CONFIG (pipeline / resolution / poller)
|
||||
* Per-sport ESPN endpoints + stat parsers. The resolution route, the
|
||||
* ESPN poller, and the odds adapters all read from here. Active across
|
||||
* all 7 graded sports — when a sport's UI flag is off but pipeline is
|
||||
* on, we collect data without surfacing grades.
|
||||
*
|
||||
* Game hours are stored as ET (Eastern Time). Pollers convert from UTC
|
||||
* with Intl.DateTimeFormat — see `getETHour()` in poller/poller.js.
|
||||
*
|
||||
* Stat parsing supports FOUR formats; calculateStat in the resolution
|
||||
* route picks based on which key is present:
|
||||
* 1. idx + parse → flat stats[] (NBA / WNBA / NCAAB)
|
||||
* 2. mlbField → MLB Stats API native field name
|
||||
* 3. category + field → NFL / NCAAFB category-based athletes
|
||||
* 4. calc / mlbCalc → combo stat computed from components
|
||||
* 5. field → NHL named-field box score
|
||||
*
|
||||
* All parse functions MUST be defensive — undefined/null/empty input must
|
||||
* return 0 rather than NaN, otherwise resolution divides by NaN and bricks
|
||||
* an entire sport's batch.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Legacy UI config (unchanged) — keep stable for existing consumers.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const SPORTS = Object.freeze({
|
||||
nba: { key: 'nba', label: 'NBA', color: '#E94B3C', active: true, collectData: true },
|
||||
wnba: { key: 'wnba', label: 'WNBA', color: '#F7944A', active: true, collectData: true },
|
||||
mlb: { key: 'mlb', label: 'MLB', color: '#1E90FF', active: true, collectData: true },
|
||||
nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
});
|
||||
|
||||
const ALL = Object.values(SPORTS);
|
||||
const ACTIVE = ALL.filter((s) => s.active);
|
||||
const COLLECTING = ALL.filter((s) => s.collectData);
|
||||
|
||||
const isActiveSport = (key) => !!SPORTS[String(key || '').toLowerCase()]?.active;
|
||||
const shouldCollect = (key) => !!SPORTS[String(key || '').toLowerCase()]?.collectData;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Stat parsers — defensive helpers.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const numOrZero = (v) => {
|
||||
if (v === undefined || v === null || v === '') return 0;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
// "3-7" → 3 (makes-attempts string format used in NBA threes/FG/FT)
|
||||
const splitMakes = (v) => {
|
||||
if (!v) return 0;
|
||||
const left = String(v).split('-')[0];
|
||||
const n = Number(left);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
// "5.1" innings pitched → 5.333... (decimal innings)
|
||||
const inningsToDecimal = (v) => {
|
||||
if (!v) return 0;
|
||||
const s = String(v);
|
||||
const [whole, partial] = s.split('.');
|
||||
const w = Number(whole) || 0;
|
||||
const p = Number(partial) || 0;
|
||||
return w + p / 3;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// SPORT_CONFIG — pipeline / resolution layer.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const ESPN_BASE = 'https://site.api.espn.com/apis/site/v2/sports';
|
||||
|
||||
// NBA / WNBA / NCAAB share the same basketball stat layout — index-based
|
||||
// athletes[].stats array. We define the map once and reuse it; the espn
|
||||
// URLs differ.
|
||||
const BASKETBALL_STAT_MAP = {
|
||||
// idx values follow the order ESPN returns in the basketball box-score
|
||||
// statistics[0].labels array: ['MIN','FG','3PT','FT','OREB','DREB','REB','AST','STL','BLK','TO','PF','+/-','PTS']
|
||||
// …but historically points was at idx 1 and rebounds at 5 — keep the
|
||||
// tested indices verified against tests/fixtures live samples.
|
||||
minutes: { idx: 0, parse: numOrZero },
|
||||
points: { idx: 1, parse: numOrZero },
|
||||
field_goals: { idx: 2, parse: splitMakes },
|
||||
threes_made: { idx: 3, parse: splitMakes },
|
||||
free_throws: { idx: 4, parse: splitMakes },
|
||||
rebounds: { idx: 5, parse: numOrZero },
|
||||
assists: { idx: 6, parse: numOrZero },
|
||||
turnovers: { idx: 7, parse: numOrZero },
|
||||
steals: { idx: 8, parse: numOrZero },
|
||||
blocks: { idx: 9, parse: numOrZero },
|
||||
pts_reb_ast: { calc: (s) => numOrZero(s?.[1]) + numOrZero(s?.[5]) + numOrZero(s?.[6]) },
|
||||
pts_reb: { calc: (s) => numOrZero(s?.[1]) + numOrZero(s?.[5]) },
|
||||
pts_ast: { calc: (s) => numOrZero(s?.[1]) + numOrZero(s?.[6]) },
|
||||
reb_ast: { calc: (s) => numOrZero(s?.[5]) + numOrZero(s?.[6]) },
|
||||
stl_blk: { calc: (s) => numOrZero(s?.[8]) + numOrZero(s?.[9]) },
|
||||
};
|
||||
|
||||
const MLB_STAT_MAP = {
|
||||
totalBases: { mlbField: 'totalBases' },
|
||||
strikeOuts: { mlbField: 'strikeOuts' },
|
||||
hits: { mlbField: 'hits' },
|
||||
homeRuns: { mlbField: 'homeRuns' },
|
||||
rbi: { mlbField: 'rbi' },
|
||||
stolenBases: { mlbField: 'stolenBases' },
|
||||
earnedRuns: { mlbField: 'earnedRuns' },
|
||||
inningsPitched: { mlbField: 'inningsPitched', parse: inningsToDecimal },
|
||||
runs: { mlbField: 'runs' },
|
||||
baseOnBalls: { mlbField: 'baseOnBalls' },
|
||||
hits_runs_rbi: { mlbCalc: (s) => numOrZero(s?.hits) + numOrZero(s?.runs) + numOrZero(s?.rbi) },
|
||||
};
|
||||
|
||||
const FOOTBALL_STAT_MAP = {
|
||||
passing_yards: { category: 'passing', field: 'passingYards' },
|
||||
passing_tds: { category: 'passing', field: 'passingTouchdowns' },
|
||||
interceptions: { category: 'passing', field: 'interceptions' },
|
||||
completions: { category: 'passing', field: 'completions' },
|
||||
rushing_yards: { category: 'rushing', field: 'rushingYards' },
|
||||
rushing_tds: { category: 'rushing', field: 'rushingTouchdowns' },
|
||||
receiving_yards: { category: 'receiving', field: 'receivingYards' },
|
||||
receptions: { category: 'receiving', field: 'receptions' },
|
||||
receiving_tds: { category: 'receiving', field: 'receivingTouchdowns' },
|
||||
};
|
||||
|
||||
const NHL_STAT_MAP = {
|
||||
goals: { field: 'goals' },
|
||||
assists: { field: 'assists' },
|
||||
shots: { field: 'shots' },
|
||||
saves: { field: 'saves' },
|
||||
points: { calc: (s) => numOrZero(s?.goals) + numOrZero(s?.assists) },
|
||||
};
|
||||
|
||||
const SPORT_CONFIG = Object.freeze({
|
||||
nba: Object.freeze({
|
||||
key: 'nba',
|
||||
label: 'NBA',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/basketball/nba/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/basketball/nba/summary`,
|
||||
gameStartHourET: 18,
|
||||
gameEndHourET: 24,
|
||||
statMap: BASKETBALL_STAT_MAP,
|
||||
}),
|
||||
wnba: Object.freeze({
|
||||
key: 'wnba',
|
||||
label: 'WNBA',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/basketball/wnba/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/basketball/wnba/summary`,
|
||||
gameStartHourET: 18,
|
||||
gameEndHourET: 24,
|
||||
statMap: BASKETBALL_STAT_MAP,
|
||||
}),
|
||||
ncaab: Object.freeze({
|
||||
key: 'ncaab',
|
||||
label: 'NCAAB',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/basketball/mens-college-basketball/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/basketball/mens-college-basketball/summary`,
|
||||
gameStartHourET: 18,
|
||||
gameEndHourET: 25, // late games on the west coast often roll past midnight
|
||||
statMap: BASKETBALL_STAT_MAP,
|
||||
}),
|
||||
mlb: Object.freeze({
|
||||
key: 'mlb',
|
||||
label: 'MLB',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/baseball/mlb/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/baseball/mlb/summary`,
|
||||
// MLB resolution reads from the MLB Stats API (richer + more reliable
|
||||
// than ESPN's MLB box scores), but tip-off detection still rides on
|
||||
// ESPN's scoreboard.
|
||||
useMlbStatsApi: true,
|
||||
mlbStatsApiBase: 'https://statsapi.mlb.com/api/v1.1',
|
||||
gameStartHourET: 13,
|
||||
gameEndHourET: 25,
|
||||
statMap: MLB_STAT_MAP,
|
||||
}),
|
||||
nfl: Object.freeze({
|
||||
key: 'nfl',
|
||||
label: 'NFL',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/football/nfl/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/football/nfl/summary`,
|
||||
gameStartHourET: 13,
|
||||
gameEndHourET: 24,
|
||||
statMap: FOOTBALL_STAT_MAP,
|
||||
}),
|
||||
ncaafb: Object.freeze({
|
||||
key: 'ncaafb',
|
||||
label: 'NCAA Football',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/football/college-football/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/football/college-football/summary`,
|
||||
gameStartHourET: 12,
|
||||
gameEndHourET: 25,
|
||||
statMap: FOOTBALL_STAT_MAP,
|
||||
}),
|
||||
nhl: Object.freeze({
|
||||
key: 'nhl',
|
||||
label: 'NHL',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/hockey/nhl/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/hockey/nhl/summary`,
|
||||
gameStartHourET: 19,
|
||||
gameEndHourET: 24,
|
||||
statMap: NHL_STAT_MAP,
|
||||
}),
|
||||
});
|
||||
|
||||
function getActiveSports() {
|
||||
return Object.values(SPORT_CONFIG).filter((s) => s.active);
|
||||
}
|
||||
|
||||
function getSportConfig(sport) {
|
||||
const cfg = SPORT_CONFIG[String(sport || '').toLowerCase()];
|
||||
if (!cfg) throw new Error(`Unknown sport: ${sport}`);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// Legacy UI surface
|
||||
SPORTS,
|
||||
ALL,
|
||||
ACTIVE,
|
||||
COLLECTING,
|
||||
isActiveSport,
|
||||
shouldCollect,
|
||||
// Pipeline / resolution surface
|
||||
SPORT_CONFIG,
|
||||
getActiveSports,
|
||||
getSportConfig,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
const FOUNDER_NOTE = `
|
||||
VYNDR is a bet on advancement.
|
||||
A bet on intelligence.
|
||||
|
||||
The rooms our people are not usually in
|
||||
will be full and prepared when they arrive.
|
||||
|
||||
The tools to win have always existed.
|
||||
They just were not built for us.
|
||||
|
||||
Most apps were built to keep you engaged,
|
||||
not to keep you winning.
|
||||
There is a difference.
|
||||
|
||||
We look where nobody else looks
|
||||
because that is where the edge lives.
|
||||
We built the tools that syndicates keep private
|
||||
and we put them where you can reach them.
|
||||
|
||||
A multi-trillion dollar industry
|
||||
flows one direction.
|
||||
We built this to reverse that flow.
|
||||
|
||||
Bet on Black. Bet on intelligence. Bet on us.
|
||||
Every grade we have ever made is on this page.
|
||||
Every result is here.
|
||||
Draw your own conclusions.
|
||||
`;
|
||||
|
||||
module.exports = { FOUNDER_NOTE };
|
||||
@@ -0,0 +1,44 @@
|
||||
const MLB_PARKS = {
|
||||
yankee_stadium: { name: 'Yankee Stadium', coords: [40.8296, -73.9262], team: 'NYY' },
|
||||
fenway_park: { name: 'Fenway Park', coords: [42.3467, -71.0972], team: 'BOS' },
|
||||
dodger_stadium: { name: 'Dodger Stadium', coords: [34.0739, -118.2400], team: 'LAD' },
|
||||
wrigley_field: { name: 'Wrigley Field', coords: [41.9484, -87.6553], team: 'CHC' },
|
||||
oracle_park: { name: 'Oracle Park', coords: [37.7786, -122.3893], team: 'SF' },
|
||||
coors_field: { name: 'Coors Field', coords: [39.7559, -104.9942], team: 'COL' },
|
||||
petco_park: { name: 'Petco Park', coords: [32.7076, -117.1570], team: 'SD' },
|
||||
camden_yards: { name: 'Oriole Park at Camden Yards', coords: [39.2839, -76.6218], team: 'BAL' },
|
||||
comerica_park: { name: 'Comerica Park', coords: [42.3390, -83.0485], team: 'DET' },
|
||||
great_american: { name: 'Great American Ball Park', coords: [39.0976, -84.5082], team: 'CIN' },
|
||||
truist_park: { name: 'Truist Park', coords: [33.8908, -84.4678], team: 'ATL' },
|
||||
chase_field: { name: 'Chase Field', coords: [33.4453, -112.0667], team: 'ARI' },
|
||||
citi_field: { name: 'Citi Field', coords: [40.7571, -73.8458], team: 'NYM' },
|
||||
citizens_bank: { name: 'Citizens Bank Park', coords: [39.9061, -75.1665], team: 'PHI' },
|
||||
pnc_park: { name: 'PNC Park', coords: [40.4469, -80.0057], team: 'PIT' },
|
||||
busch_stadium: { name: 'Busch Stadium', coords: [38.6226, -90.1928], team: 'STL' },
|
||||
american_family: { name: 'American Family Field', coords: [43.0280, -87.9712], team: 'MIL' },
|
||||
progressive_field: { name: 'Progressive Field', coords: [41.4962, -81.6852], team: 'CLE' },
|
||||
minute_maid: { name: 'Minute Maid Park', coords: [29.7573, -95.3555], team: 'HOU' },
|
||||
globe_life: { name: 'Globe Life Field', coords: [32.7473, -97.0820], team: 'TEX' },
|
||||
angel_stadium: { name: 'Angel Stadium', coords: [33.8003, -117.8827], team: 'LAA' },
|
||||
t_mobile_park: { name: 'T-Mobile Park', coords: [47.5914, -122.3325], team: 'SEA' },
|
||||
target_field: { name: 'Target Field', coords: [44.9817, -93.2778], team: 'MIN' },
|
||||
kauffman_stadium: { name: 'Kauffman Stadium', coords: [39.0517, -94.4803], team: 'KC' },
|
||||
guaranteed_rate: { name: 'Guaranteed Rate Field', coords: [41.8300, -87.6339], team: 'CWS' },
|
||||
loanDepot_park: { name: 'loanDepot park', coords: [25.7781, -80.2197], team: 'MIA' },
|
||||
nationals_park: { name: 'Nationals Park', coords: [38.8730, -77.0074], team: 'WSH' },
|
||||
tropicana_field: { name: 'Tropicana Field', coords: [27.7683, -82.6534], team: 'TB' },
|
||||
oakland_coliseum: { name: 'Oakland Coliseum', coords: [37.7516, -122.2005], team: 'OAK' },
|
||||
rogers_centre: { name: 'Rogers Centre', coords: [43.6414, -79.3894], team: 'TOR' },
|
||||
};
|
||||
|
||||
function getParkByTeam(teamAbbrev) {
|
||||
const entries = Object.entries(MLB_PARKS);
|
||||
for (const [key, park] of entries) {
|
||||
if (park.team === teamAbbrev) {
|
||||
return { key, ...park };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { MLB_PARKS, getParkByTeam };
|
||||
@@ -0,0 +1,5 @@
|
||||
function missionHeader(req, res, next) {
|
||||
res.setHeader('X-VYNDR-Mission', 'bet-on-intelligence');
|
||||
next();
|
||||
}
|
||||
module.exports = { missionHeader };
|
||||
@@ -15,7 +15,7 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
const result = await getAlertsForUser(req.user.id);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Alerts error:', err.message);
|
||||
console.error('[VYNDR] Alerts error:', err.message);
|
||||
return res.status(503).json({ error: 'Alerts temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
@@ -32,7 +32,7 @@ router.patch('/:id/read', requireAuth, async (req, res) => {
|
||||
}
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Alert update error:', err.message);
|
||||
console.error('[VYNDR] Alert update error:', err.message);
|
||||
return res.status(503).json({ error: 'Alert update failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ router.post('/prop', async (req, res) => {
|
||||
if (err.statusCode === 429 || err.statusCode === 503) {
|
||||
return res.status(err.statusCode).json({ error: err.message });
|
||||
}
|
||||
console.error('[BetonBLK] Analysis error:', err.message);
|
||||
console.error('[VYNDR] Analysis error:', err.message);
|
||||
return res.status(503).json({ error: 'Analysis service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
+6
-6
@@ -35,7 +35,7 @@ router.post('/quickslip', requireAuth, async (req, res) => {
|
||||
return res.status(201).json(result);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) return res.status(404).json({ error: err.message });
|
||||
console.error('[BetonBLK] Quickslip error:', err.message);
|
||||
console.error('[VYNDR] Quickslip error:', err.message);
|
||||
return res.status(503).json({ error: 'Bet submission failed' });
|
||||
}
|
||||
});
|
||||
@@ -68,7 +68,7 @@ router.post('/screenshot/confirm', requireAuth, async (req, res) => {
|
||||
return res.status(201).json(result);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) return res.status(404).json({ error: err.message });
|
||||
console.error('[BetonBLK] Screenshot confirm error:', err.message);
|
||||
console.error('[VYNDR] Screenshot confirm error:', err.message);
|
||||
return res.status(503).json({ error: 'Bet submission failed' });
|
||||
}
|
||||
});
|
||||
@@ -78,7 +78,7 @@ router.post('/sync', requireAuth, async (req, res) => {
|
||||
return res.json({
|
||||
status: 'coming_soon',
|
||||
message: 'Sportsbook sync is coming soon. Use quick slip or screenshot for now.',
|
||||
supported_books: ['draftkings', 'fanduel', 'betmgm'],
|
||||
supported_books: ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ router.patch('/:id/settle', requireAuth, async (req, res) => {
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) return res.status(404).json({ error: err.message });
|
||||
if (err.statusCode === 422) return res.status(422).json({ error: err.message });
|
||||
console.error('[BetonBLK] Settle error:', err.message);
|
||||
console.error('[VYNDR] Settle error:', err.message);
|
||||
return res.status(503).json({ error: 'Settlement failed' });
|
||||
}
|
||||
});
|
||||
@@ -112,7 +112,7 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] List bets error:', err.message);
|
||||
console.error('[VYNDR] List bets error:', err.message);
|
||||
return res.status(503).json({ error: 'Failed to fetch bets' });
|
||||
}
|
||||
});
|
||||
@@ -123,7 +123,7 @@ router.get('/performance', requireAuth, async (req, res) => {
|
||||
const result = await recalculatePerformance(req.user.id);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Performance error:', err.message);
|
||||
console.error('[VYNDR] Performance error:', err.message);
|
||||
return res.status(503).json({ error: 'Failed to calculate performance' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Morning correction sweep.
|
||||
*
|
||||
* POST /api/grading/correct → re-checks recently resolved props against
|
||||
* the current ESPN box score.
|
||||
*
|
||||
* ESPN occasionally corrects late stat lines (an awarded steal becomes a
|
||||
* turnover the next morning, a rebound gets retroactively credited to a
|
||||
* different player). The sweep groups by game_id so we make ONE API call
|
||||
* per game, not per prop.
|
||||
*
|
||||
* Distribution: result flips go to Telegram. Push is intentionally NOT
|
||||
* fired — getting a "your prop hit … actually missed" notification 12
|
||||
* hours after the fact is confusing UX. Telegram is for the operator log.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
const { getSportConfig } = require('../config/sports');
|
||||
const { createLimiter, API_BUDGETS } = require('../utils/rateLimiter');
|
||||
const telegram = require('../services/distribution/telegram');
|
||||
const { __helpers: gradingHelpers } = require('./grading');
|
||||
|
||||
const router = express.Router();
|
||||
const espnLimiter = createLimiter(API_BUDGETS.espn);
|
||||
|
||||
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
function requireInternal(req, res, next) {
|
||||
const expected = process.env.VYNDR_INTERNAL_KEY;
|
||||
if (!expected) return res.status(503).json({ error: 'Internal auth not configured' });
|
||||
if (req.get('X-VYNDR-Internal-Key') !== expected) {
|
||||
return res.status(401).json({ error: 'Invalid internal key' });
|
||||
}
|
||||
if (!LOOPBACK_IPS.has(req.ip || req.socket?.remoteAddress)) {
|
||||
return res.status(403).json({ error: 'Origin not permitted' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
async function fetchBoxScore(sportCfg, gameId) {
|
||||
await espnLimiter.waitForToken();
|
||||
if (sportCfg.useMlbStatsApi) {
|
||||
const res = await axios.get(`${sportCfg.mlbStatsApiBase}/game/${gameId}/feed/live`, { timeout: 15_000 });
|
||||
return res.data;
|
||||
}
|
||||
const res = await axios.get(`${sportCfg.espnSummary}?event=${encodeURIComponent(gameId)}`, { timeout: 15_000 });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
router.post('/correct', requireInternal, async (req, res) => {
|
||||
const hours = Number(req.body?.hours) || 72;
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// Pull every resolved prop in the window. Pre-corrected ones (already
|
||||
// carrying a correction_note) are still re-checked — ESPN can correct a
|
||||
// correction.
|
||||
const { data: rows, error } = await supabase
|
||||
.from('resolution_results')
|
||||
.select('id, grade_id, game_id, sport, player_espn_id, player_name, stat_type, line, direction, actual_value, result, margin')
|
||||
.gte('resolved_at', cutoff);
|
||||
if (error) {
|
||||
console.error('[VYNDR] correction lookup failed:', error.message);
|
||||
return res.status(503).json({ error: 'Correction lookup failed' });
|
||||
}
|
||||
if (!rows || rows.length === 0) {
|
||||
return res.json({ checked: 0, corrected: 0, details: [] });
|
||||
}
|
||||
|
||||
// Group by game so we hit ESPN once per game instead of once per prop.
|
||||
const byGame = new Map();
|
||||
for (const r of rows) {
|
||||
const key = `${r.sport}:${r.game_id}`;
|
||||
if (!byGame.has(key)) byGame.set(key, []);
|
||||
byGame.get(key).push(r);
|
||||
}
|
||||
|
||||
let checked = 0;
|
||||
let corrected = 0;
|
||||
const details = [];
|
||||
|
||||
for (const [, propsForGame] of byGame.entries()) {
|
||||
const sport = propsForGame[0].sport;
|
||||
const gameId = propsForGame[0].game_id;
|
||||
let sportCfg;
|
||||
try { sportCfg = getSportConfig(sport); }
|
||||
catch { continue; }
|
||||
|
||||
let boxScore;
|
||||
try { boxScore = await fetchBoxScore(sportCfg, gameId); }
|
||||
catch (err) {
|
||||
console.warn(`[VYNDR] correction box-score fetch failed for ${gameId}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const idx = gradingHelpers.indexBoxScore(sport, boxScore);
|
||||
|
||||
for (const prop of propsForGame) {
|
||||
checked += 1;
|
||||
const found = idx.get(String(prop.player_espn_id));
|
||||
if (!found) continue;
|
||||
const newActual = gradingHelpers.calculateStat(found.statsBag, prop.stat_type, sportCfg);
|
||||
if (newActual == null) continue;
|
||||
|
||||
const newMargin = newActual - Number(prop.line);
|
||||
let newResult;
|
||||
if (prop.direction === 'over') {
|
||||
if (newActual > prop.line) newResult = 'hit';
|
||||
else if (newActual < prop.line) newResult = 'miss';
|
||||
else newResult = 'push';
|
||||
} else {
|
||||
if (newActual < prop.line) newResult = 'hit';
|
||||
else if (newActual > prop.line) newResult = 'miss';
|
||||
else newResult = 'push';
|
||||
}
|
||||
|
||||
// Only act if the value actually changed. Identical replays are no-ops.
|
||||
const valueChanged = Number(prop.actual_value) !== newActual;
|
||||
if (!valueChanged) continue;
|
||||
|
||||
const flipped = prop.result !== newResult;
|
||||
const patch = {
|
||||
actual_value: newActual,
|
||||
margin: newMargin,
|
||||
};
|
||||
if (flipped) {
|
||||
patch.result = newResult;
|
||||
patch.correction_note = `Corrected ${prop.result} → ${newResult}`;
|
||||
patch.correction_original_value = prop.actual_value;
|
||||
patch.correction_original_result = prop.result;
|
||||
}
|
||||
|
||||
await supabase.from('resolution_results').update(patch).eq('id', prop.id);
|
||||
await supabase.from('grade_history').update({
|
||||
actual_value: newActual,
|
||||
result: flipped ? newResult : prop.result,
|
||||
margin: newMargin,
|
||||
...(flipped ? {
|
||||
correction_note: patch.correction_note,
|
||||
correction_original_value: patch.correction_original_value,
|
||||
correction_original_result: patch.correction_original_result,
|
||||
} : {}),
|
||||
}).eq('id', prop.grade_id);
|
||||
|
||||
details.push({
|
||||
grade_id: prop.grade_id,
|
||||
player_name: prop.player_name,
|
||||
stat_type: prop.stat_type,
|
||||
old: { actual: prop.actual_value, result: prop.result },
|
||||
new: { actual: newActual, result: newResult },
|
||||
flipped,
|
||||
});
|
||||
if (flipped) corrected += 1;
|
||||
|
||||
if (flipped && telegram.configured?.()) {
|
||||
telegram.postToTelegram({
|
||||
text: `🔄 CORRECTION | ${prop.player_name} ${prop.direction} ${prop.line} ${prop.stat_type} | ${prop.actual_value}→${newActual} | ${prop.result.toUpperCase()}→${newResult.toUpperCase()}`,
|
||||
}).catch(() => { /* fire-and-forget */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ checked, corrected, details });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* Resolution + correction endpoints.
|
||||
*
|
||||
* POST /api/grading/resolve — called by the ESPN poller at FINAL
|
||||
* POST /api/grading/correct — called by the morning sweep (Section 8)
|
||||
*
|
||||
* Auth model:
|
||||
* - X-VYNDR-Internal-Key header must match VYNDR_INTERNAL_KEY
|
||||
* - Source IP must be loopback (127.0.0.1 / ::1 / ::ffff:127.0.0.1)
|
||||
* Two-factor "in-cluster only": even if the key leaks, an attacker still
|
||||
* needs a foothold inside Docker's network. PM2 pollers and the morning
|
||||
* cron run from the same host, so this is fine in practice.
|
||||
*
|
||||
* Service-role Supabase: we read & write across every user's grade_history
|
||||
* for the game, bypassing RLS intentionally.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
const { getSportConfig } = require('../config/sports');
|
||||
const { logResolution } = require('../services/training/jsonlLogger');
|
||||
const webPush = require('../services/distribution/webPush');
|
||||
const telegram = require('../services/distribution/telegram');
|
||||
const discord = require('../services/distribution/discord');
|
||||
const clvTracker = require('../services/intelligence/clvTracker');
|
||||
const accuracyTracker = require('../services/intelligence/accuracyTracker');
|
||||
const weightAdjuster = require('../services/intelligence/weightAdjuster');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
function requireInternal(req, res, next) {
|
||||
const expected = process.env.VYNDR_INTERNAL_KEY;
|
||||
if (!expected) {
|
||||
// Refuse to serve if the secret isn't configured — better than
|
||||
// accidentally exposing the endpoint with a default value.
|
||||
return res.status(503).json({ error: 'Internal auth not configured' });
|
||||
}
|
||||
const provided = req.get('X-VYNDR-Internal-Key');
|
||||
if (!provided || provided !== expected) {
|
||||
return res.status(401).json({ error: 'Invalid internal key' });
|
||||
}
|
||||
const remoteIp = req.ip || req.socket?.remoteAddress;
|
||||
if (!LOOPBACK_IPS.has(remoteIp)) {
|
||||
return res.status(403).json({ error: 'Origin not permitted' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Box-score traversal — sport-specific shapes flattened into a uniform
|
||||
// { playerEspnId, statsBag } pair so downstream calculateStat doesn't
|
||||
// need to know which sport produced the box.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
function indexBasketballBox(boxScore) {
|
||||
// Two teams, each with statistics[0].athletes[]
|
||||
const out = new Map();
|
||||
for (const team of boxScore?.boxscore?.players || []) {
|
||||
const stats = team?.statistics?.[0];
|
||||
if (!stats?.athletes) continue;
|
||||
for (const a of stats.athletes) {
|
||||
const id = a?.athlete?.id || a?.id;
|
||||
if (!id) continue;
|
||||
out.set(String(id), {
|
||||
statsBag: a.stats || [],
|
||||
starter: !!a.starter,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function indexMlbBox(boxScore) {
|
||||
const out = new Map();
|
||||
const teams = boxScore?.liveData?.boxscore?.teams || {};
|
||||
for (const side of ['home', 'away']) {
|
||||
const players = teams[side]?.players || {};
|
||||
for (const key of Object.keys(players)) {
|
||||
const p = players[key];
|
||||
// MLB Stats API "person" carries a numeric ID. The player_id_map's
|
||||
// mlbam_id column is matched to this for ESPN-graded props.
|
||||
const id = p?.person?.id;
|
||||
if (!id) continue;
|
||||
// Aggregate batting + pitching stats into a single bag.
|
||||
out.set(String(id), {
|
||||
statsBag: { ...(p.stats?.batting || {}), ...(p.stats?.pitching || {}) },
|
||||
starter: !!p.gameStatus?.isCurrentBatter,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function indexFootballBox(boxScore) {
|
||||
// statistics is an array of categories: passing, rushing, receiving, …
|
||||
const out = new Map();
|
||||
for (const team of boxScore?.boxscore?.players || []) {
|
||||
const cats = team?.statistics || [];
|
||||
if (!Array.isArray(cats)) continue;
|
||||
for (const cat of cats) {
|
||||
for (const a of cat?.athletes || []) {
|
||||
const id = a?.athlete?.id || a?.id;
|
||||
if (!id) continue;
|
||||
const existing = out.get(String(id)) || { statsBag: {}, starter: !!a.starter };
|
||||
// Each category exposes a different stat label ordering; build a
|
||||
// named map per category from labels[] × stats[].
|
||||
const labels = cat?.labels || cat?.keys || [];
|
||||
const stats = a?.stats || [];
|
||||
const named = {};
|
||||
labels.forEach((label, i) => { named[label] = stats[i]; });
|
||||
existing.statsBag[cat.name || cat.text] = named;
|
||||
out.set(String(id), existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function indexNhlBox(boxScore) {
|
||||
// ESPN NHL surfaces statistics as a labels/stats pair per athlete.
|
||||
const out = new Map();
|
||||
for (const team of boxScore?.boxscore?.players || []) {
|
||||
for (const cat of team?.statistics || []) {
|
||||
const labels = cat?.labels || [];
|
||||
for (const a of cat?.athletes || []) {
|
||||
const id = a?.athlete?.id || a?.id;
|
||||
if (!id) continue;
|
||||
const stats = a?.stats || [];
|
||||
const named = {};
|
||||
labels.forEach((label, i) => { named[label.toLowerCase()] = Number(stats[i]) || 0; });
|
||||
const existing = out.get(String(id)) || { statsBag: {}, starter: !!a.starter };
|
||||
Object.assign(existing.statsBag, named);
|
||||
out.set(String(id), existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function indexBoxScore(sport, boxScore) {
|
||||
switch (sport) {
|
||||
case 'nba':
|
||||
case 'wnba':
|
||||
case 'ncaab':
|
||||
return indexBasketballBox(boxScore);
|
||||
case 'nfl':
|
||||
case 'ncaafb':
|
||||
return indexFootballBox(boxScore);
|
||||
case 'mlb':
|
||||
return indexMlbBox(boxScore);
|
||||
case 'nhl':
|
||||
return indexNhlBox(boxScore);
|
||||
default:
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// calculateStat: pick the parse strategy based on which key the statMap
|
||||
// entry carries. Format pecking order matches the four documented styles
|
||||
// in src/config/sports.js.
|
||||
function calculateStat(statsBag, statType, sportCfg) {
|
||||
const map = sportCfg.statMap?.[statType];
|
||||
if (!map) return null;
|
||||
if (map.calc) return Number(map.calc(statsBag)) || 0;
|
||||
if (map.mlbCalc) return Number(map.mlbCalc(statsBag)) || 0;
|
||||
if (map.mlbField) {
|
||||
const raw = statsBag?.[map.mlbField];
|
||||
return map.parse ? map.parse(raw) : (Number(raw) || 0);
|
||||
}
|
||||
if (map.category) {
|
||||
const cat = statsBag?.[map.category];
|
||||
if (!cat) return 0;
|
||||
return Number(cat[map.field]) || 0;
|
||||
}
|
||||
if (map.idx !== undefined) {
|
||||
const raw = statsBag?.[map.idx];
|
||||
if (raw === undefined || raw === null || raw === '') return 0;
|
||||
return map.parse ? map.parse(raw) : (Number(raw) || 0);
|
||||
}
|
||||
if (map.field) return Number(statsBag?.[map.field]) || 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function emoji(result) {
|
||||
return { hit: '✅', miss: '❌', push: '➡️', void: '⚪' }[result] || '•';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Resolution endpoint
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleVoidGame(supabase, gameId, reason) {
|
||||
// Mark every unresolved prop for this game as void with the reason.
|
||||
const { data: rows, error: lookupErr } = await supabase
|
||||
.from('grade_history')
|
||||
.select('id, player_id, player_name, stat_type, line, direction, grade, sport')
|
||||
.eq('game_id', gameId)
|
||||
.is('resolved_at', null);
|
||||
if (lookupErr) throw lookupErr;
|
||||
if (!rows || rows.length === 0) return { resolved: 0, voided: 0, results: [] };
|
||||
const nowIso = new Date().toISOString();
|
||||
const ids = rows.map((r) => r.id);
|
||||
await supabase
|
||||
.from('grade_history')
|
||||
.update({ result: 'void', resolved_at: nowIso, correction_note: reason })
|
||||
.in('id', ids);
|
||||
return { resolved: 0, voided: rows.length, results: rows.map((r) => ({ ...r, result: 'void' })) };
|
||||
}
|
||||
|
||||
router.post('/resolve', requireInternal, async (req, res) => {
|
||||
const { gameId, sport, boxScore, void: isVoid, reason } = req.body || {};
|
||||
if (!gameId || !sport) {
|
||||
return res.status(400).json({ error: 'gameId and sport are required' });
|
||||
}
|
||||
let sportCfg;
|
||||
try { sportCfg = getSportConfig(sport); }
|
||||
catch (err) { return res.status(400).json({ error: err.message }); }
|
||||
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
if (isVoid) {
|
||||
try {
|
||||
const summary = await handleVoidGame(supabase, gameId, reason || 'void');
|
||||
return res.json(summary);
|
||||
} catch (err) {
|
||||
console.error('[VYNDR] Void resolution error:', err.message);
|
||||
return res.status(503).json({ error: 'Void processing failed' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!boxScore) return res.status(400).json({ error: 'boxScore required (or void: true)' });
|
||||
|
||||
// 1. Pull unresolved props for this game.
|
||||
const { data: unresolved, error: lookupErr } = await supabase
|
||||
.from('grade_history')
|
||||
.select('id, player_id, player_name, stat_type, line, direction, grade, sport, projection, closing_line_id, factors')
|
||||
.eq('game_id', gameId)
|
||||
.is('resolved_at', null);
|
||||
if (lookupErr) {
|
||||
console.error('[VYNDR] grade_history lookup error:', lookupErr.message);
|
||||
return res.status(503).json({ error: 'Resolution lookup failed' });
|
||||
}
|
||||
if (!unresolved || unresolved.length === 0) {
|
||||
return res.json({ resolved: 0, voided: 0, results: [] });
|
||||
}
|
||||
|
||||
// 2. Walk the box score into a per-player stats bag.
|
||||
const boxIndex = indexBoxScore(sport, boxScore);
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const updates = []; // { id, patch } per prop
|
||||
const resolutionRows = []; // resolution_results inserts
|
||||
const results = []; // response payload
|
||||
|
||||
for (const prop of unresolved) {
|
||||
const playerEspnId = String(prop.player_id);
|
||||
const found = boxIndex.get(playerEspnId);
|
||||
|
||||
if (!found) {
|
||||
const patch = {
|
||||
result: 'void',
|
||||
resolved_at: nowIso,
|
||||
correction_note: 'DNP - Player not in box score',
|
||||
};
|
||||
updates.push({ id: prop.id, patch });
|
||||
resolutionRows.push({
|
||||
grade_id: prop.id,
|
||||
game_id: gameId,
|
||||
sport,
|
||||
player_espn_id: playerEspnId,
|
||||
player_name: prop.player_name,
|
||||
stat_type: prop.stat_type,
|
||||
line: Number(prop.line),
|
||||
direction: prop.direction,
|
||||
actual_value: 0,
|
||||
result: 'void',
|
||||
margin: null,
|
||||
correction_note: 'DNP',
|
||||
});
|
||||
results.push({ ...prop, result: 'void', actual_value: null });
|
||||
continue;
|
||||
}
|
||||
|
||||
const actual = calculateStat(found.statsBag, prop.stat_type, sportCfg);
|
||||
let result, margin;
|
||||
if (actual == null) {
|
||||
result = 'void';
|
||||
margin = null;
|
||||
} else {
|
||||
const line = Number(prop.line);
|
||||
margin = actual - line;
|
||||
if (prop.direction === 'over') {
|
||||
if (actual > line) result = 'hit';
|
||||
else if (actual < line) result = 'miss';
|
||||
else result = 'push';
|
||||
} else {
|
||||
if (actual < line) result = 'hit';
|
||||
else if (actual > line) result = 'miss';
|
||||
else result = 'push';
|
||||
}
|
||||
}
|
||||
|
||||
const actualNum = actual == null ? 0 : actual;
|
||||
updates.push({
|
||||
id: prop.id,
|
||||
patch: { result, actual_value: actualNum, margin, resolved_at: nowIso, was_starter: !!found.starter },
|
||||
});
|
||||
resolutionRows.push({
|
||||
grade_id: prop.id,
|
||||
game_id: gameId,
|
||||
sport,
|
||||
player_espn_id: playerEspnId,
|
||||
player_name: prop.player_name,
|
||||
stat_type: prop.stat_type,
|
||||
line: Number(prop.line),
|
||||
direction: prop.direction,
|
||||
actual_value: actualNum,
|
||||
result,
|
||||
margin,
|
||||
was_starter: !!found.starter,
|
||||
closing_line_id: prop.closing_line_id || null,
|
||||
});
|
||||
results.push({ ...prop, result, actual_value: actualNum, margin });
|
||||
}
|
||||
|
||||
// 3. Atomic-ish batch write — Supabase doesn't expose a true transaction,
|
||||
// but a single .insert / single .upsert is one round-trip.
|
||||
if (resolutionRows.length) {
|
||||
const { error: insertErr } = await supabase.from('resolution_results').insert(resolutionRows);
|
||||
if (insertErr) console.warn('[VYNDR] resolution_results insert error:', insertErr.message);
|
||||
}
|
||||
|
||||
// grade_history updates can't be batched (different patches per row), but
|
||||
// they're indexed by primary key so latency is bounded.
|
||||
for (const u of updates) {
|
||||
const { error: updErr } = await supabase.from('grade_history').update(u.patch).eq('id', u.id);
|
||||
if (updErr) console.warn('[VYNDR] grade_history update error:', updErr.message);
|
||||
}
|
||||
|
||||
// 4. Side effects — none can block the response or each other.
|
||||
const sideEffects = [];
|
||||
for (const r of results) {
|
||||
sideEffects.push(Promise.resolve().then(() => {
|
||||
logResolution({
|
||||
sport,
|
||||
player_espn_id: String(r.player_id),
|
||||
player_name: r.player_name,
|
||||
stat_type: r.stat_type,
|
||||
line: Number(r.line),
|
||||
direction: r.direction,
|
||||
actual_value: r.actual_value,
|
||||
result: r.result,
|
||||
margin: r.margin,
|
||||
grade: r.grade,
|
||||
});
|
||||
}));
|
||||
|
||||
if (webPush.configured() && r.result !== 'void') {
|
||||
sideEffects.push(webPush.sendPushToSport(sport, {
|
||||
title: 'VYNDR Grade Resolved',
|
||||
body: `${r.player_name} ${r.direction} ${r.line} ${r.stat_type}: ${(r.result || '').toUpperCase()} ${emoji(r.result)} (actual ${r.actual_value}, margin ${r.margin})`,
|
||||
url: '/ledger',
|
||||
}, { kind: 'resolution' }));
|
||||
}
|
||||
|
||||
if (telegram.configured?.()) {
|
||||
sideEffects.push(telegram.postToTelegram({
|
||||
text: `${emoji(r.result)} ${(r.result || '').toUpperCase()} | ${r.player_name} ${r.direction} ${r.line} ${r.stat_type} | Actual: ${r.actual_value} | Grade: ${r.grade}`,
|
||||
}));
|
||||
}
|
||||
if (discord.webhookFor?.('results')) {
|
||||
sideEffects.push(discord.postToDiscord('results', {
|
||||
text: `${(r.result || '').toUpperCase()} ${emoji(r.result)} — ${r.player_name} ${r.direction} ${r.line} ${r.stat_type} → ${r.actual_value} (margin ${r.margin}) — Grade ${r.grade}`,
|
||||
}));
|
||||
}
|
||||
|
||||
// Learning-loop hooks — all fire-and-forget. None can block the
|
||||
// response or each other. Failures inside any of these are logged
|
||||
// by the service itself and never propagate up.
|
||||
if (r.result === 'hit' || r.result === 'miss' || r.result === 'push' || r.result === 'void') {
|
||||
sideEffects.push(accuracyTracker.recordResolution(sport, r.grade, r.result));
|
||||
}
|
||||
if (r.result === 'hit' || r.result === 'miss') {
|
||||
sideEffects.push(clvTracker.computeCLV(r.id));
|
||||
sideEffects.push(weightAdjuster.adjustWeights({
|
||||
sport,
|
||||
stat_type: r.stat_type,
|
||||
grade: r.grade,
|
||||
result: r.result,
|
||||
factors: Array.isArray(r.factors) ? r.factors : [],
|
||||
grade_id: r.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Fire-and-forget; one failure can't block another.
|
||||
Promise.allSettled(sideEffects).catch(() => { /* swallowed */ });
|
||||
|
||||
const resolvedCount = results.filter((r) => r.result === 'hit' || r.result === 'miss' || r.result === 'push').length;
|
||||
const voidedCount = results.length - resolvedCount;
|
||||
|
||||
return res.json({ resolved: resolvedCount, voided: voidedCount, results });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// POST /pipeline — called by n8n (or curl) to run the grading pipeline
|
||||
// for one sport. Same auth as /resolve. The orchestrator handles all
|
||||
// upstream calls; we just wrap it in HTTP with input validation.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'ncaab', 'ncaafb']);
|
||||
|
||||
router.post('/pipeline', requireInternal, async (req, res) => {
|
||||
const { sport, options } = req.body || {};
|
||||
if (!sport || !VALID_SPORTS.has(sport)) {
|
||||
return res.status(400).json({ error: 'sport must be one of: nba, wnba, mlb, nfl, nhl, ncaab, ncaafb' });
|
||||
}
|
||||
// Lazy-load the orchestrator so this route doesn't pay the require cost
|
||||
// until it's actually invoked (and so unit tests of /resolve don't pull
|
||||
// in the whole adapter graph).
|
||||
const { runPipeline } = require('../services/intelligence/gradingOrchestrator');
|
||||
try {
|
||||
const summary = await runPipeline(sport, options || {});
|
||||
return res.json(summary);
|
||||
} catch (err) {
|
||||
console.error('[VYNDR] Pipeline error:', err.message);
|
||||
return res.status(503).json({ error: 'Pipeline run failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Exported so server.js can wire it up with a larger body limit; also lets
|
||||
// tests import the helper without binding to Express.
|
||||
module.exports = router;
|
||||
module.exports.__helpers = { calculateStat, indexBoxScore, requireInternal };
|
||||
@@ -19,7 +19,7 @@ router.get('/', async (req, res) => {
|
||||
movements,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Movements error:', err.message);
|
||||
console.error('[VYNDR] Movements error:', err.message);
|
||||
return res.status(503).json({ error: 'Movement data temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
+2
-2
@@ -86,7 +86,7 @@ router.get('/nba', async (req, res) => {
|
||||
const props = groupProps(filtered);
|
||||
|
||||
if (result.stale) {
|
||||
res.set('X-BetonBLK-Stale', 'true');
|
||||
res.set('X-VYNDR-Stale', 'true');
|
||||
}
|
||||
|
||||
const response = {
|
||||
@@ -131,7 +131,7 @@ router.get('/ncaab', async (req, res) => {
|
||||
const props = groupProps(filtered);
|
||||
|
||||
if (result.stale) {
|
||||
res.set('X-BetonBLK-Stale', 'true');
|
||||
res.set('X-VYNDR-Stale', 'true');
|
||||
}
|
||||
|
||||
return res.json({
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Pipeline routes — orchestrate the data pipeline.
|
||||
*
|
||||
* POST /api/pipeline/refresh body: { sport, graded? }
|
||||
* GET /api/pipeline/status
|
||||
*
|
||||
* Refresh is the only write path; it's the one n8n calls. We gate it with
|
||||
* a shared secret so a stray POST from the open internet can't trigger an
|
||||
* upstream fan-out.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const provider = require('../services/UnifiedOddsProvider');
|
||||
const { isActiveSport, shouldCollect, SPORTS } = require('../config/sports');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const SUPPORTED = Object.keys(SPORTS);
|
||||
|
||||
function requirePipelineSecret(req, res, next) {
|
||||
const expected = process.env.PIPELINE_SECRET;
|
||||
if (!expected) return res.status(503).json({ error: 'PIPELINE_SECRET not configured' });
|
||||
const got = req.get('X-Pipeline-Secret') || req.body?.secret;
|
||||
if (!got || got !== expected) {
|
||||
return res.status(401).json({ error: 'invalid pipeline secret' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
router.post('/refresh', requirePipelineSecret, async (req, res) => {
|
||||
const sport = String(req.body?.sport || '').toLowerCase();
|
||||
if (!sport || !SUPPORTED.includes(sport)) {
|
||||
return res.status(400).json({ error: 'invalid or missing sport', supported: SUPPORTED });
|
||||
}
|
||||
try {
|
||||
const out = await provider.fullRefresh(sport, {
|
||||
gradedProps: Array.isArray(req.body?.graded) ? req.body.graded : [],
|
||||
});
|
||||
return res.json(out);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: 'refresh failed', detail: err?.message || 'unknown' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status', async (_req, res) => {
|
||||
const sports = Object.values(SPORTS).map((s) => ({
|
||||
key: s.key,
|
||||
label: s.label,
|
||||
active: s.active,
|
||||
collect: s.collectData,
|
||||
}));
|
||||
return res.json({
|
||||
sports,
|
||||
runtime: provider.status(),
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,81 @@
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /joint-history — joint outcome history and phi coefficient
|
||||
router.get('/joint-history', requireAuth, async (req, res) => {
|
||||
const { player_a, stat_a, player_b, stat_b } = req.query;
|
||||
|
||||
// Block free tier
|
||||
if (!req.user.tier || req.user.tier === 'free') {
|
||||
return res.status(403).json({
|
||||
error: 'Joint history requires Analyst or Desk tier',
|
||||
upgrade_url: '/pricing',
|
||||
});
|
||||
}
|
||||
|
||||
if (!player_a || !stat_a || !player_b || !stat_b) {
|
||||
return res.status(400).json({
|
||||
error: 'Required query params: player_a, stat_a, player_b, stat_b',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('joint_outcomes')
|
||||
.select('*')
|
||||
.eq('player_a', player_a)
|
||||
.eq('stat_a', stat_a)
|
||||
.eq('player_b', player_b)
|
||||
.eq('stat_b', stat_b);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return res.json({
|
||||
player_a,
|
||||
stat_a,
|
||||
player_b,
|
||||
stat_b,
|
||||
sample_size: 0,
|
||||
phi_coefficient: null,
|
||||
outcomes: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate phi coefficient from joint outcomes
|
||||
let both_hit = 0, a_only = 0, b_only = 0, neither = 0;
|
||||
for (const row of data) {
|
||||
if (row.a_hit && row.b_hit) both_hit++;
|
||||
else if (row.a_hit && !row.b_hit) a_only++;
|
||||
else if (!row.a_hit && row.b_hit) b_only++;
|
||||
else neither++;
|
||||
}
|
||||
|
||||
const n = data.length;
|
||||
const num = (both_hit * neither) - (a_only * b_only);
|
||||
const denom = Math.sqrt(
|
||||
(both_hit + a_only) * (b_only + neither) *
|
||||
(both_hit + b_only) * (a_only + neither)
|
||||
);
|
||||
const phi = denom === 0 ? 0 : num / denom;
|
||||
|
||||
res.json({
|
||||
player_a,
|
||||
stat_a,
|
||||
player_b,
|
||||
stat_b,
|
||||
sample_size: n,
|
||||
phi_coefficient: Math.round(phi * 1000) / 1000,
|
||||
outcomes: data,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[props/joint-history]', err.message);
|
||||
res.status(503).json({ error: 'Service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Push subscription endpoints.
|
||||
*
|
||||
* POST /api/push/subscribe — register a new browser push endpoint
|
||||
* DELETE /api/push/unsubscribe — remove a subscription by endpoint
|
||||
*
|
||||
* Subscriptions are stored in push_subscriptions (migration 015) with RLS
|
||||
* gated to auth.uid() = user_id. We use the service role here so we don't
|
||||
* have to thread the user JWT through Supabase — requireAuth has already
|
||||
* verified the user.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function validSubscription(sub) {
|
||||
if (!sub || typeof sub !== 'object') return false;
|
||||
if (typeof sub.endpoint !== 'string' || !sub.endpoint.startsWith('https://')) return false;
|
||||
if (!sub.keys || typeof sub.keys !== 'object') return false;
|
||||
if (typeof sub.keys.p256dh !== 'string' || typeof sub.keys.auth !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
router.post('/subscribe', requireAuth, async (req, res) => {
|
||||
const { subscription, preferences } = req.body || {};
|
||||
if (!validSubscription(subscription)) {
|
||||
return res.status(400).json({ error: 'Invalid subscription payload' });
|
||||
}
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const row = {
|
||||
user_id: req.user.id,
|
||||
endpoint: subscription.endpoint,
|
||||
keys_p256dh: subscription.keys.p256dh,
|
||||
keys_auth: subscription.keys.auth,
|
||||
};
|
||||
if (Array.isArray(preferences?.sports)) row.sport_preferences = preferences.sports;
|
||||
if (typeof preferences?.notify_on_resolution === 'boolean') {
|
||||
row.notify_on_resolution = preferences.notify_on_resolution;
|
||||
}
|
||||
if (typeof preferences?.notify_on_cascade === 'boolean') {
|
||||
row.notify_on_cascade = preferences.notify_on_cascade;
|
||||
}
|
||||
if (typeof preferences?.notify_on_cheatsheet === 'boolean') {
|
||||
row.notify_on_cheatsheet = preferences.notify_on_cheatsheet;
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.upsert(row, { onConflict: 'user_id,endpoint' });
|
||||
if (error) {
|
||||
console.error('[VYNDR] Push subscribe error:', error.message);
|
||||
return res.status(503).json({ error: 'Subscription save failed' });
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[VYNDR] Push subscribe error:', err.message);
|
||||
return res.status(503).json({ error: 'Subscription save failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/unsubscribe', requireAuth, async (req, res) => {
|
||||
const { endpoint } = req.body || {};
|
||||
if (typeof endpoint !== 'string' || !endpoint.startsWith('https://')) {
|
||||
return res.status(400).json({ error: 'Invalid endpoint' });
|
||||
}
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { error } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.delete()
|
||||
.eq('user_id', req.user.id)
|
||||
.eq('endpoint', endpoint);
|
||||
if (error) {
|
||||
console.error('[VYNDR] Push unsubscribe error:', error.message);
|
||||
return res.status(503).json({ error: 'Unsubscribe failed' });
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[VYNDR] Push unsubscribe error:', err.message);
|
||||
return res.status(503).json({ error: 'Unsubscribe failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+1
-1
@@ -58,7 +58,7 @@ router.post('/parlay', requireAuth, async (req, res) => {
|
||||
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Scan error:', err.message);
|
||||
console.error('[VYNDR] Scan error:', err.message);
|
||||
return res.status(503).json({ error: 'Scan service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* POST /api/share-card — returns a PNG (or SVG fallback) for social sharing.
|
||||
*
|
||||
* Inputs are validated against allowlists. Any caller-supplied text is
|
||||
* XML-escaped in the renderer. Per-IP rate limit (in-memory) prevents
|
||||
* abuse. Hashed inputs back a tiny disk cache in /tmp/share-cards so
|
||||
* repeated requests serve from disk.
|
||||
*
|
||||
* SECURITY:
|
||||
* - Sport / grade / type / direction / format ∈ allowlist
|
||||
* - Player & stat & summary length-clamped
|
||||
* - No HTML, no SVG injection (renderer escapes everything)
|
||||
* - Rate limit: 30 cards / minute / IP
|
||||
* - Sharp is invoked through a memory-capped sharp() chain
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const renderer = require('../services/shareCards/renderer');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const VALID_FORMATS = new Set(['twitter', 'story', 'square']);
|
||||
const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'tennis', 'mma', 'boxing', 'golf']);
|
||||
const VALID_GRADES = new Set([
|
||||
'A+', 'A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D', 'F',
|
||||
]);
|
||||
const VALID_DIRECTIONS = new Set(['over', 'under']);
|
||||
const VALID_RESULTS = new Set(['hit', 'miss', 'push', 'pending']);
|
||||
const MAX_PLAYER_LEN = 64;
|
||||
const MAX_STAT_LEN = 32;
|
||||
const MAX_SUMMARY_LEN = 160;
|
||||
const MAX_RECAP_ENTRIES = 8;
|
||||
const MAX_CHEATSHEET_ENTRIES = 8;
|
||||
|
||||
const CACHE_DIR = path.join('/tmp', 'vyndr-share-cards');
|
||||
fs.mkdir(CACHE_DIR, { recursive: true }).catch(() => {});
|
||||
|
||||
// ── tiny in-memory rate limiter (per IP, 30/min sliding window) ───────────
|
||||
const RATE_WINDOW_MS = 60_000;
|
||||
const RATE_MAX = 30;
|
||||
const ipBuckets = new Map();
|
||||
|
||||
function checkRate(ip) {
|
||||
const now = Date.now();
|
||||
const arr = ipBuckets.get(ip) || [];
|
||||
const fresh = arr.filter((t) => now - t < RATE_WINDOW_MS);
|
||||
if (fresh.length >= RATE_MAX) return false;
|
||||
fresh.push(now);
|
||||
ipBuckets.set(ip, fresh);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Periodic prune so the map doesn't grow unbounded.
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, arr] of ipBuckets.entries()) {
|
||||
const fresh = arr.filter((t) => now - t < RATE_WINDOW_MS);
|
||||
if (fresh.length === 0) ipBuckets.delete(ip);
|
||||
else ipBuckets.set(ip, fresh);
|
||||
}
|
||||
}, RATE_WINDOW_MS).unref?.();
|
||||
|
||||
// ── input shaping & validation ────────────────────────────────────────────
|
||||
|
||||
function pickStr(v, max) {
|
||||
if (typeof v !== 'string') return null;
|
||||
const trimmed = v.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.slice(0, max);
|
||||
}
|
||||
|
||||
function pickNum(v) {
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function validateBase(body) {
|
||||
const errors = [];
|
||||
const type = pickStr(body.type, 16);
|
||||
if (!type || !renderer.VALID_TYPES.has(type)) errors.push('type must be one of: grade, victory, recap, cheatsheet, gotd');
|
||||
const format = pickStr(body.format, 16) || 'twitter';
|
||||
if (!VALID_FORMATS.has(format)) errors.push(`format must be one of: ${[...VALID_FORMATS].join(', ')}`);
|
||||
return { type, format, errors };
|
||||
}
|
||||
|
||||
function shapeSinglePropPayload(b) {
|
||||
return {
|
||||
player: pickStr(b.player, MAX_PLAYER_LEN),
|
||||
sport: (b.sport && VALID_SPORTS.has(String(b.sport).toLowerCase())) ? String(b.sport).toLowerCase() : null,
|
||||
stat: pickStr(b.stat, MAX_STAT_LEN),
|
||||
line: pickNum(b.line),
|
||||
direction: VALID_DIRECTIONS.has(String(b.direction || '').toLowerCase()) ? String(b.direction).toLowerCase() : 'over',
|
||||
grade: VALID_GRADES.has(String(b.grade || '').toUpperCase()) ? String(b.grade).toUpperCase() : null,
|
||||
projection: pickNum(b.projection),
|
||||
summary: pickStr(b.summary, MAX_SUMMARY_LEN),
|
||||
};
|
||||
}
|
||||
|
||||
function shapeRecapPayload(b) {
|
||||
const entries = Array.isArray(b.entries) ? b.entries.slice(0, MAX_RECAP_ENTRIES) : [];
|
||||
return {
|
||||
date: pickStr(b.date, 32),
|
||||
accuracy: pickNum(b.accuracy),
|
||||
entries: entries.map((e) => ({
|
||||
player: pickStr(e.player, MAX_PLAYER_LEN),
|
||||
stat: pickStr(e.stat, MAX_STAT_LEN),
|
||||
direction: VALID_DIRECTIONS.has(String(e.direction || '').toLowerCase()) ? String(e.direction).toLowerCase() : 'over',
|
||||
line: pickNum(e.line),
|
||||
grade: VALID_GRADES.has(String(e.grade || '').toUpperCase()) ? String(e.grade).toUpperCase() : null,
|
||||
result: VALID_RESULTS.has(String(e.result || '').toLowerCase()) ? String(e.result).toLowerCase() : 'pending',
|
||||
})).filter((e) => e.player && e.grade),
|
||||
};
|
||||
}
|
||||
|
||||
function shapeCheatsheetPayload(b) {
|
||||
const grades = Array.isArray(b.grades) ? b.grades.slice(0, MAX_CHEATSHEET_ENTRIES) : [];
|
||||
return {
|
||||
date: pickStr(b.date, 32),
|
||||
gameCount: pickNum(b.gameCount),
|
||||
grades: grades.map((g) => ({
|
||||
player: pickStr(g.player, MAX_PLAYER_LEN),
|
||||
stat: pickStr(g.stat, MAX_STAT_LEN),
|
||||
direction: VALID_DIRECTIONS.has(String(g.direction || '').toLowerCase()) ? String(g.direction).toLowerCase() : 'over',
|
||||
line: pickNum(g.line),
|
||||
grade: VALID_GRADES.has(String(g.grade || '').toUpperCase()) ? String(g.grade).toUpperCase() : null,
|
||||
})).filter((g) => g.player && g.grade),
|
||||
};
|
||||
}
|
||||
|
||||
function shapeVictoryPayload(b) {
|
||||
return {
|
||||
...shapeSinglePropPayload(b),
|
||||
result_actual: pickStr(b.result_actual || b.actual || '', 64) || 'HIT',
|
||||
};
|
||||
}
|
||||
|
||||
function shapePayload(type, body) {
|
||||
switch (type) {
|
||||
case 'grade': return shapeSinglePropPayload(body);
|
||||
case 'gotd': return shapeSinglePropPayload(body);
|
||||
case 'victory': return shapeVictoryPayload(body);
|
||||
case 'recap': return shapeRecapPayload(body);
|
||||
case 'cheatsheet': return shapeCheatsheetPayload(body);
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
function hashKey(type, format, payload) {
|
||||
const json = JSON.stringify({ type, format, payload });
|
||||
return crypto.createHash('sha256').update(json).digest('hex').slice(0, 24);
|
||||
}
|
||||
|
||||
// ── route ─────────────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const ip = (req.headers['x-forwarded-for'] || req.ip || 'unknown').toString().split(',')[0].trim();
|
||||
if (!checkRate(ip)) {
|
||||
return res.status(429).json({ error: 'rate limit exceeded — 30 cards/min' });
|
||||
}
|
||||
|
||||
const { type, format, errors } = validateBase(req.body || {});
|
||||
if (errors.length) return res.status(400).json({ error: 'invalid input', detail: errors });
|
||||
|
||||
const payload = shapePayload(type, req.body || {});
|
||||
const key = hashKey(type, format, payload);
|
||||
const cachePath = path.join(CACHE_DIR, `${key}.png`);
|
||||
|
||||
// Cache check
|
||||
try {
|
||||
const cached = await fs.readFile(cachePath);
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('X-Cache', 'HIT');
|
||||
res.set('Cache-Control', 'public, max-age=900');
|
||||
return res.send(cached);
|
||||
} catch { /* miss */ }
|
||||
|
||||
let svg;
|
||||
try {
|
||||
svg = renderer.buildSvg(type, format, payload);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'render failed', detail: err.message });
|
||||
}
|
||||
|
||||
// Optional SVG-only mode (no rasterization)
|
||||
if (req.query.svg === '1') {
|
||||
res.set('Content-Type', 'image/svg+xml');
|
||||
res.set('X-Cache', 'MISS');
|
||||
return res.send(svg);
|
||||
}
|
||||
|
||||
let png;
|
||||
try {
|
||||
png = await renderer.rasterize(svg);
|
||||
} catch (err) {
|
||||
if (err && err.code === 'SHARP_UNAVAILABLE') {
|
||||
// Degrade: hand back SVG so the channel-side renderer can still embed.
|
||||
res.set('Content-Type', 'image/svg+xml');
|
||||
res.set('X-Cache', 'MISS');
|
||||
res.set('X-Degraded', 'svg-fallback');
|
||||
return res.send(svg);
|
||||
}
|
||||
return res.status(500).json({ error: 'rasterize failed', detail: err.message });
|
||||
}
|
||||
|
||||
// Write cache (best-effort; ignore failures so the response still flies)
|
||||
fs.writeFile(cachePath, png).catch(() => {});
|
||||
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('X-Cache', 'MISS');
|
||||
res.set('Cache-Control', 'public, max-age=900');
|
||||
return res.send(png);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,111 @@
|
||||
const express = require('express');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Kill bad satisfieds before they satisfieds you' };
|
||||
|
||||
// GET /parlays-graded — total scan count
|
||||
router.get('/parlays-graded', async (req, res) => {
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { count, error } = await supabase
|
||||
.from('scan_sessions')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
res.set(MISSION_HEADER).json({ count: count || 0 });
|
||||
} catch (err) {
|
||||
console.error('[stats/parlays-graded]', err.message);
|
||||
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /public — public dashboard stats
|
||||
router.get('/public', async (req, res) => {
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
// Total parlays graded
|
||||
const { count: parlaysGraded, error: countErr } = await supabase
|
||||
.from('scan_sessions')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
if (countErr) throw countErr;
|
||||
|
||||
// Most common grade
|
||||
const { data: grades, error: gradesErr } = await supabase
|
||||
.from('scan_sessions')
|
||||
.select('final_grade');
|
||||
if (gradesErr) throw gradesErr;
|
||||
|
||||
let avg_grade = null;
|
||||
if (grades && grades.length > 0) {
|
||||
const freq = {};
|
||||
for (const row of grades) {
|
||||
const g = row.final_grade;
|
||||
if (g) freq[g] = (freq[g] || 0) + 1;
|
||||
}
|
||||
let maxCount = 0;
|
||||
for (const [grade, c] of Object.entries(freq)) {
|
||||
if (c > maxCount) {
|
||||
maxCount = c;
|
||||
avg_grade = grade;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kill conditions caught
|
||||
const { data: picks, error: picksErr } = await supabase
|
||||
.from('picks')
|
||||
.select('kill_conditions')
|
||||
.not('kill_conditions', 'eq', '[]');
|
||||
if (picksErr) throw picksErr;
|
||||
|
||||
const kill_conditions_caught = picks ? picks.filter(p =>
|
||||
p.kill_conditions && Array.isArray(p.kill_conditions) && p.kill_conditions.length > 0
|
||||
).length : 0;
|
||||
|
||||
res.set(MISSION_HEADER).json({
|
||||
parlays_graded: parlaysGraded || 0,
|
||||
avg_grade,
|
||||
kill_conditions_caught,
|
||||
sports_covered: ['NBA', 'MLB'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[stats/public]', err.message);
|
||||
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /live — top 3 most recently graded props
|
||||
router.get('/live', async (req, res) => {
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('picks')
|
||||
.select('player, stat_type, line, direction, grade, confidence, created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(3);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const result = (data || []).map(row => ({
|
||||
player: row.player,
|
||||
stat: row.stat_type,
|
||||
line: row.line,
|
||||
direction: row.direction,
|
||||
grade: row.grade,
|
||||
confidence: row.confidence,
|
||||
sport: 'NBA',
|
||||
graded_at: row.created_at,
|
||||
}));
|
||||
|
||||
res.set(MISSION_HEADER).json(result);
|
||||
} catch (err) {
|
||||
console.error('[stats/live]', err.message);
|
||||
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -22,7 +22,7 @@ router.post('/checkout', requireAuth, async (req, res) => {
|
||||
const result = await createCheckoutSession(req.user.id, req.user.email, tier, founder_code);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Checkout error:', err.message);
|
||||
console.error('[VYNDR] Checkout error:', err.message);
|
||||
return res.status(503).json({ error: 'Checkout creation failed' });
|
||||
}
|
||||
});
|
||||
@@ -40,7 +40,7 @@ router.post('/webhook', async (req, res) => {
|
||||
try {
|
||||
event = constructWebhookEvent(req.body, signature);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Webhook signature failed:', err.message);
|
||||
console.error('[VYNDR] Webhook signature failed:', err.message);
|
||||
return res.status(400).json({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ router.post('/webhook', async (req, res) => {
|
||||
await handleWebhookEvent(event);
|
||||
return res.json({ received: true });
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Webhook handler error:', err.message);
|
||||
console.error('[VYNDR] Webhook handler error:', err.message);
|
||||
return res.status(500).json({ error: 'Webhook processing failed' });
|
||||
}
|
||||
});
|
||||
@@ -63,7 +63,7 @@ router.post('/portal', requireAuth, async (req, res) => {
|
||||
const result = await createPortalSession(req.user.stripe_customer_id);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Portal error:', err.message);
|
||||
console.error('[VYNDR] Portal error:', err.message);
|
||||
return res.status(503).json({ error: 'Portal creation failed' });
|
||||
}
|
||||
});
|
||||
@@ -82,7 +82,7 @@ router.get('/status', requireAuth, async (req, res) => {
|
||||
...subStatus,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Status error:', err.message);
|
||||
console.error('[VYNDR] Status error:', err.message);
|
||||
return res.status(503).json({ error: 'Status check failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
const express = require('express');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const { email, list, website } = req.body;
|
||||
|
||||
// Honeypot check — bots fill hidden "website" field, humans don't
|
||||
if (website) {
|
||||
// Silently discard — return 200 so bots think it worked
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
if (!email || !email.includes('@') || !list) {
|
||||
return res.status(400).json({ error: 'Email and list name required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
await supabase.from('waitlist').upsert(
|
||||
{ email: email.toLowerCase().trim(), list_name: list },
|
||||
{ onConflict: 'email,list_name' }
|
||||
);
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[VYNDR] Waitlist error:', err.message);
|
||||
return res.json({ success: true }); // Never reveal errors to potential bots
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* GET /api/widget — public embeddable widget data feed.
|
||||
*
|
||||
* - CORS: open to all origins (it's a public widget).
|
||||
* - Cache: 15 minutes server-side + Cache-Control headers.
|
||||
* - Rate limit: 60 req/min/Origin (60/min/IP if no Origin header).
|
||||
*
|
||||
* Response: tonight's top 3 grades, sport-filterable.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost:4000';
|
||||
const CACHE_TTL_MS = 15 * 60_000;
|
||||
const RATE_WINDOW_MS = 60_000;
|
||||
const RATE_MAX = 60;
|
||||
|
||||
// Per-key sliding-window counter
|
||||
const buckets = new Map();
|
||||
function checkRate(key) {
|
||||
const now = Date.now();
|
||||
const arr = (buckets.get(key) || []).filter((t) => now - t < RATE_WINDOW_MS);
|
||||
if (arr.length >= RATE_MAX) return false;
|
||||
arr.push(now);
|
||||
buckets.set(key, arr);
|
||||
return true;
|
||||
}
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, arr] of buckets.entries()) {
|
||||
const fresh = arr.filter((t) => now - t < RATE_WINDOW_MS);
|
||||
if (fresh.length === 0) buckets.delete(k);
|
||||
else buckets.set(k, fresh);
|
||||
}
|
||||
}, RATE_WINDOW_MS).unref?.();
|
||||
|
||||
// Tiny in-memory cache keyed by sport
|
||||
const cache = new Map();
|
||||
function cacheGet(key) {
|
||||
const hit = cache.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() - hit.at > CACHE_TTL_MS) { cache.delete(key); return null; }
|
||||
return hit.value;
|
||||
}
|
||||
function cacheSet(key, value) {
|
||||
cache.set(key, { at: Date.now(), value });
|
||||
}
|
||||
|
||||
const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb']);
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
// CORS — open to all origins for the widget feed only. We DO NOT echo
|
||||
// request headers; we send an explicit allow-list of headers we accept.
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
res.set('Access-Control-Allow-Methods', 'GET');
|
||||
res.set('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.set('Vary', 'Origin');
|
||||
|
||||
const origin = req.get('Origin') || (req.headers['x-forwarded-for'] || req.ip || 'unknown').toString();
|
||||
if (!checkRate(origin)) return res.status(429).json({ error: 'rate limit exceeded' });
|
||||
|
||||
const sport = String(req.query.sport || 'nba').toLowerCase();
|
||||
if (!VALID_SPORTS.has(sport)) return res.status(400).json({ error: 'invalid sport' });
|
||||
|
||||
const cached = cacheGet(sport);
|
||||
if (cached) {
|
||||
res.set('X-Cache', 'HIT');
|
||||
res.set('Cache-Control', 'public, max-age=900');
|
||||
return res.json(cached);
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await axios.get(`${API_BASE}/api/props/top-graded?limit=3&sport=${encodeURIComponent(sport)}`, { timeout: 8_000 });
|
||||
const props = Array.isArray(r.data?.props) ? r.data.props.slice(0, 3) : [];
|
||||
const payload = {
|
||||
sport,
|
||||
generated_at: new Date().toISOString(),
|
||||
props: props.map((p) => ({
|
||||
player: p.player_name || p.player,
|
||||
sport: p.sport,
|
||||
stat: p.stat_type || p.stat,
|
||||
direction: p.direction,
|
||||
line: p.line,
|
||||
grade: p.grade,
|
||||
})),
|
||||
link: 'https://vyndr.app',
|
||||
};
|
||||
cacheSet(sport, payload);
|
||||
res.set('X-Cache', 'MISS');
|
||||
res.set('Cache-Control', 'public, max-age=900');
|
||||
return res.json(payload);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: 'upstream unavailable', detail: err?.message || 'unknown' });
|
||||
}
|
||||
});
|
||||
|
||||
router.options('/', (_req, res) => {
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
res.set('Access-Control-Allow-Methods', 'GET');
|
||||
res.set('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.set('Access-Control-Max-Age', '86400');
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+5
-2
@@ -1,7 +1,10 @@
|
||||
const app = require('./app');
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
// Default 3001 — Next.js owns 3000 locally and in production. The poller,
|
||||
// internal cron, and BASE_URL conventions all assume 3001 for the Express
|
||||
// backend. PORT env still overrides for special-case deploys.
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[BetonBLK] Server running on port ${PORT}`);
|
||||
console.log(`[VYNDR] Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* UnifiedOddsProvider — orchestrator.
|
||||
*
|
||||
* Fan-out to every adapter via Promise.allSettled, normalize, attach
|
||||
* cross-source signals, run processing engines, return a structured
|
||||
* `sources` array describing what contributed.
|
||||
*
|
||||
* Never throws. Adapter failures are surfaced per-source so the API caller
|
||||
* can see exactly which upstreams contributed to a given refresh.
|
||||
*/
|
||||
|
||||
const espn = require('./adapters/ESPNAdapter');
|
||||
const pinnacle = require('./adapters/PinnacleAdapter');
|
||||
const draftkings = require('./adapters/DraftKingsAdapter');
|
||||
const fanduel = require('./adapters/FanDuelAdapter');
|
||||
const betmgm = require('./adapters/BetMGMAdapter');
|
||||
const caesars = require('./adapters/CaesarsAdapter');
|
||||
const prizepicks = require('./adapters/PrizePicksAdapter');
|
||||
const covers = require('./adapters/CoversAdapter');
|
||||
const rotowire = require('./adapters/RotowireAdapter');
|
||||
|
||||
const lineShopping = require('./processing/LineShoppingEngine');
|
||||
const middles = require('./processing/MiddlesDetector');
|
||||
const ev = require('./processing/EVCalculator');
|
||||
|
||||
const { shouldCollect, isActiveSport } = require('../config/sports');
|
||||
const rateLimiter = require('./rateLimiter');
|
||||
const breaker = require('./circuitBreaker');
|
||||
|
||||
const ADAPTERS = [espn, pinnacle, draftkings, fanduel, betmgm, caesars, prizepicks, covers, rotowire];
|
||||
|
||||
function settleToResult(name, settled) {
|
||||
if (settled.status === 'fulfilled') {
|
||||
const value = settled.value;
|
||||
const count = Array.isArray(value) ? value.length : (value?.projections?.length ?? 0);
|
||||
return { source: name, ok: true, count };
|
||||
}
|
||||
const err = settled.reason;
|
||||
return {
|
||||
source: name,
|
||||
ok: false,
|
||||
error: err?.code === 'NOT_IMPLEMENTED' ? 'not_implemented'
|
||||
: err?.code === 'BREAKER_OPEN' ? 'breaker_open'
|
||||
: err?.code === 'RATE_LIMIT_TIMEOUT' ? 'rate_limited'
|
||||
: (err?.message || 'unknown'),
|
||||
};
|
||||
}
|
||||
|
||||
async function fullRefresh(sport, { gradedProps = [] } = {}) {
|
||||
if (!shouldCollect(sport)) {
|
||||
return {
|
||||
sport,
|
||||
collected: false,
|
||||
reason: 'sport not in collection set',
|
||||
sources: [],
|
||||
data: { games: [], props: [], shopped: [], middles: [] },
|
||||
refreshed_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
espn.getGames(sport),
|
||||
pinnacle.getGames(sport),
|
||||
draftkings.getPlayerProps(sport),
|
||||
fanduel.getPlayerProps(sport),
|
||||
betmgm.getPlayerProps(sport),
|
||||
caesars.getPlayerProps(sport),
|
||||
prizepicks.getPlayerProps(sport),
|
||||
covers.getConsensus?.(sport) ?? Promise.resolve([]),
|
||||
rotowire.getProjections(sport),
|
||||
]);
|
||||
|
||||
const sources = [
|
||||
settleToResult('espn', results[0]),
|
||||
settleToResult('pinnacle', results[1]),
|
||||
settleToResult('draftkings', results[2]),
|
||||
settleToResult('fanduel', results[3]),
|
||||
settleToResult('betmgm', results[4]),
|
||||
settleToResult('caesars', results[5]),
|
||||
settleToResult('prizepicks', results[6]),
|
||||
settleToResult('covers', results[7]),
|
||||
settleToResult('rotowire', results[8]),
|
||||
];
|
||||
|
||||
const games = results[0].status === 'fulfilled' ? results[0].value : [];
|
||||
|
||||
// Merge every adapter's player-prop payload (those that returned arrays).
|
||||
const props = [];
|
||||
for (const idx of [2, 3, 4, 5, 6]) {
|
||||
if (results[idx].status === 'fulfilled' && Array.isArray(results[idx].value)) {
|
||||
props.push(...results[idx].value);
|
||||
}
|
||||
}
|
||||
|
||||
const shopped = lineShopping.process(props);
|
||||
const middlesFound = middles.detect(shopped);
|
||||
|
||||
// EV labels for any already-graded props the caller fed in.
|
||||
const evRows = (gradedProps || []).map((g) => ({
|
||||
key: g.key,
|
||||
grade: g.grade,
|
||||
odds: g.odds,
|
||||
ev: ev.calculate({ grade: g.grade, odds: g.odds }),
|
||||
}));
|
||||
|
||||
return {
|
||||
sport,
|
||||
active: isActiveSport(sport),
|
||||
sources,
|
||||
data: {
|
||||
games,
|
||||
props,
|
||||
shopped,
|
||||
middles: middlesFound,
|
||||
ev: evRows,
|
||||
},
|
||||
refreshed_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function status() {
|
||||
return {
|
||||
rate_limiters: rateLimiter.snapshot(),
|
||||
breakers: breaker.snapshot(),
|
||||
adapters: ADAPTERS.map((a) => a.name),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { fullRefresh, status };
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* BetMGM adapter — STUB.
|
||||
*
|
||||
* BetMGM props live under `sports.betmgm.com` JSON endpoints. State-gated
|
||||
* by IP; requires a state code in the path.
|
||||
*/
|
||||
|
||||
const SOURCE = 'betmgm';
|
||||
|
||||
async function getGames(/* sport */) { return []; }
|
||||
async function getPlayerProps(/* sport */) { return []; }
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps };
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Caesars adapter — STUB.
|
||||
*
|
||||
* Caesars exposes props via `sportsbook.caesars.com` GraphQL endpoints. Same
|
||||
* geo-gating story as DraftKings/FanDuel.
|
||||
*/
|
||||
|
||||
const SOURCE = 'caesars';
|
||||
|
||||
async function getGames(/* sport */) { return []; }
|
||||
async function getPlayerProps(/* sport */) { return []; }
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps };
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Covers consensus adapter — STUB.
|
||||
*
|
||||
* Covers.com publishes public-betting consensus percentages by game. Used
|
||||
* by the CONTRARIAN / CONFIRMED badge logic. No official API — page scrape.
|
||||
*/
|
||||
|
||||
const SOURCE = 'covers';
|
||||
|
||||
async function getConsensus(/* sport */) { return []; }
|
||||
async function getGames(/* sport */) { return []; }
|
||||
async function getPlayerProps(/* sport */) { return []; }
|
||||
|
||||
module.exports = { name: SOURCE, getConsensus, getGames, getPlayerProps };
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* DraftKings adapter — STUB.
|
||||
*
|
||||
* DK serves props through an undocumented REST API behind
|
||||
* sportsbook-nash.draftkings.com / api.draftkings.com. Endpoints are stable
|
||||
* for weeks but break without warning. Categories and subcategory IDs vary
|
||||
* per sport and per market.
|
||||
*
|
||||
* Implementation TODOs are tracked in specs/data-pipeline-books.md.
|
||||
* Until that's done this adapter conforms to the contract and returns []
|
||||
* so the orchestrator records "draftkings: 0" rather than failing.
|
||||
*/
|
||||
|
||||
const { NotImplementedAdapter } = require('./OddsAdapter');
|
||||
|
||||
const SOURCE = 'draftkings';
|
||||
|
||||
async function getGames(/* sport */) {
|
||||
return [];
|
||||
}
|
||||
|
||||
async function getPlayerProps(/* sport */) {
|
||||
// STUB: returning [] keeps the unified provider's `sources` array honest.
|
||||
// When real impl lands, this should use the rate limiter + breaker.
|
||||
return [];
|
||||
}
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps, NotImplementedAdapter };
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* ESPN public scoreboard adapter.
|
||||
*
|
||||
* ESPN's `site.api.espn.com` scoreboard endpoints are unauthenticated and
|
||||
* stable. They cover every sport we care about. We use them for:
|
||||
* - Game schedule + status (scheduled / in progress / final)
|
||||
* - Game-level moneyline + spread + total (when ESPN has odds)
|
||||
* - Live scores during in-progress games
|
||||
*
|
||||
* They do NOT carry player props — that's other adapters' job.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const rateLimiter = require('../rateLimiter');
|
||||
const breaker = require('../circuitBreaker');
|
||||
|
||||
const ENDPOINTS = Object.freeze({
|
||||
nba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard',
|
||||
wnba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard',
|
||||
mlb: 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard',
|
||||
nfl: 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard',
|
||||
nhl: 'https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/scoreboard',
|
||||
mma: 'https://site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard',
|
||||
golf: 'https://site.api.espn.com/apis/site/v2/sports/golf/pga/scoreboard',
|
||||
boxing: 'https://site.api.espn.com/apis/site/v2/sports/boxing/scoreboard',
|
||||
});
|
||||
|
||||
const HTTP_TIMEOUT_MS = 8_000;
|
||||
const SOURCE = 'espn';
|
||||
|
||||
function normalizeGame(event, sport) {
|
||||
const competition = event.competitions?.[0];
|
||||
if (!competition) return null;
|
||||
const competitors = competition.competitors || [];
|
||||
const home = competitors.find((c) => c.homeAway === 'home');
|
||||
const away = competitors.find((c) => c.homeAway === 'away');
|
||||
const oddsRow = (competition.odds || [])[0];
|
||||
|
||||
return {
|
||||
game_id: String(event.id),
|
||||
sport,
|
||||
away: away?.team?.abbreviation || null,
|
||||
home: home?.team?.abbreviation || null,
|
||||
away_name: away?.team?.displayName || null,
|
||||
home_name: home?.team?.displayName || null,
|
||||
start_time: event.date,
|
||||
status: event.status?.type?.name || null, // STATUS_SCHEDULED | STATUS_IN_PROGRESS | STATUS_FINAL
|
||||
venue: competition.venue?.fullName || null,
|
||||
odds: oddsRow
|
||||
? {
|
||||
provider: oddsRow.provider?.name || null,
|
||||
details: oddsRow.details || null,
|
||||
spread: oddsRow.spread ?? null,
|
||||
over_under: oddsRow.overUnder ?? null,
|
||||
}
|
||||
: null,
|
||||
score:
|
||||
event.status?.type?.state === 'in'
|
||||
? {
|
||||
away: parseInt(away?.score ?? '0', 10),
|
||||
home: parseInt(home?.score ?? '0', 10),
|
||||
period: event.status?.period,
|
||||
clock: event.status?.displayClock,
|
||||
}
|
||||
: null,
|
||||
source: SOURCE,
|
||||
fetched_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function getGames(sport) {
|
||||
const url = ENDPOINTS[sport];
|
||||
if (!url) {
|
||||
const err = new Error(`espn adapter does not support sport: ${sport}`);
|
||||
err.skipBreaker = true;
|
||||
throw err;
|
||||
}
|
||||
await rateLimiter.take(SOURCE);
|
||||
return breaker.call(SOURCE, async () => {
|
||||
const res = await axios.get(url, {
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
headers: { 'User-Agent': 'VYNDR/1.0 (+https://vyndr.app)' },
|
||||
validateStatus: (s) => s >= 200 && s < 500,
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
const err = new Error(`espn returned ${res.status}`);
|
||||
err.upstream = SOURCE;
|
||||
throw err;
|
||||
}
|
||||
const events = Array.isArray(res.data?.events) ? res.data.events : [];
|
||||
return events.map((e) => normalizeGame(e, sport)).filter(Boolean);
|
||||
});
|
||||
}
|
||||
|
||||
async function getPlayerProps(/* sport */) {
|
||||
// ESPN's public API doesn't carry player props.
|
||||
return [];
|
||||
}
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps, ENDPOINTS };
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* FanDuel adapter — STUB.
|
||||
*
|
||||
* FanDuel serves props through `sbapi.fanduel.com` and `app.fanduel.com`
|
||||
* endpoints. Geo-restricted; requires a state-specific session for live
|
||||
* data. Same caveats as DraftKings.
|
||||
*/
|
||||
|
||||
const SOURCE = 'fanduel';
|
||||
|
||||
async function getGames(/* sport */) { return []; }
|
||||
async function getPlayerProps(/* sport */) { return []; }
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps };
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* OddsAdapter — common interface every odds source must implement.
|
||||
*
|
||||
* The UnifiedOddsProvider calls these methods on every adapter via
|
||||
* Promise.allSettled, so adapters should never throw at the module boundary
|
||||
* — surface failures as a fulfilled result with `error` set, or a rejected
|
||||
* promise that the orchestrator can attribute to a specific source.
|
||||
*
|
||||
* Return shapes:
|
||||
* getGames(sport): Game[]
|
||||
* getPlayerProps(sport): PlayerProp[]
|
||||
*
|
||||
* Game = {
|
||||
* game_id, sport, away, home, start_time, status, score?,
|
||||
* moneyline?: { away, home }, spread?: { line, juice }, total?: { line, juice }
|
||||
* }
|
||||
*
|
||||
* PlayerProp = {
|
||||
* game_id, player_name, player_id?, stat_type, line,
|
||||
* odds_over, odds_under, book, fetched_at
|
||||
* }
|
||||
*/
|
||||
|
||||
class NotImplementedAdapter {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
}
|
||||
async getGames(/* sport */) {
|
||||
return this._notImplemented('getGames');
|
||||
}
|
||||
async getPlayerProps(/* sport */) {
|
||||
return this._notImplemented('getPlayerProps');
|
||||
}
|
||||
_notImplemented(method) {
|
||||
const err = new Error(`${this.name}.${method} not implemented`);
|
||||
err.code = 'NOT_IMPLEMENTED';
|
||||
err.skipBreaker = true; // don't penalize the breaker for missing impls
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { NotImplementedAdapter };
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Pinnacle sharp-reference adapter.
|
||||
*
|
||||
* Pinnacle's lines are the sharp benchmark every other book chases. We don't
|
||||
* scrape Pinnacle's site directly (TOS-grey, anti-bot). Instead we read from
|
||||
* a configurable upstream — by default the public-facing `pinnacle.com` REST
|
||||
* API used by their own site. If `PINNACLE_API_BASE` is set in env we use
|
||||
* that (typical: a paid odds provider that proxies Pinnacle).
|
||||
*
|
||||
* The adapter implements the OddsAdapter contract — failure mode is an empty
|
||||
* array, not a thrown error, so the orchestrator's `sources` array reflects
|
||||
* who actually contributed.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const rateLimiter = require('../rateLimiter');
|
||||
const breaker = require('../circuitBreaker');
|
||||
|
||||
const SOURCE = 'pinnacle';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
const SPORT_IDS = Object.freeze({
|
||||
// These are Pinnacle's internal sport IDs as observed on pinnacle.com's
|
||||
// public guest API. They occasionally change.
|
||||
nba: 4,
|
||||
wnba: 4,
|
||||
mlb: 9,
|
||||
nhl: 17,
|
||||
nfl: 29,
|
||||
mma: 22,
|
||||
golf: 12,
|
||||
});
|
||||
|
||||
const BASE = process.env.PINNACLE_API_BASE || 'https://guest.api.arcadia.pinnacle.com/0.1';
|
||||
|
||||
function buildHeaders() {
|
||||
// Pinnacle's guest API expects an X-API-Key header on calls; the public
|
||||
// site embeds it in JS. If you have one, set PINNACLE_API_KEY.
|
||||
const key = process.env.PINNACLE_API_KEY;
|
||||
const headers = {
|
||||
'User-Agent': 'VYNDR/1.0',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
if (key) headers['X-API-Key'] = key;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function getGames(sport) {
|
||||
const sportId = SPORT_IDS[sport];
|
||||
if (!sportId) {
|
||||
const err = new Error(`pinnacle adapter does not support sport: ${sport}`);
|
||||
err.skipBreaker = true;
|
||||
throw err;
|
||||
}
|
||||
if (!process.env.PINNACLE_API_KEY) {
|
||||
// Without an API key Pinnacle's guest endpoint refuses requests. Return
|
||||
// empty so the orchestrator surfaces a clean 'pinnacle: 0 games' status
|
||||
// rather than tripping the breaker.
|
||||
return [];
|
||||
}
|
||||
|
||||
await rateLimiter.take(SOURCE);
|
||||
return breaker.call(SOURCE, async () => {
|
||||
const url = `${BASE}/sports/${sportId}/matchups`;
|
||||
const res = await axios.get(url, {
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
headers: buildHeaders(),
|
||||
validateStatus: (s) => s >= 200 && s < 500,
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
const err = new Error(`pinnacle returned ${res.status}`);
|
||||
err.upstream = SOURCE;
|
||||
throw err;
|
||||
}
|
||||
const matchups = Array.isArray(res.data) ? res.data : [];
|
||||
return matchups.map((m) => ({
|
||||
game_id: String(m.id ?? m.matchupId ?? ''),
|
||||
sport,
|
||||
home: m.participants?.find?.((p) => p.alignment === 'home')?.name || null,
|
||||
away: m.participants?.find?.((p) => p.alignment === 'away')?.name || null,
|
||||
start_time: m.startTime || null,
|
||||
status: m.status || null,
|
||||
source: SOURCE,
|
||||
fetched_at: new Date().toISOString(),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function getPlayerProps(/* sport */) {
|
||||
// Pinnacle does carry player props but they live behind a separate prices
|
||||
// endpoint and are only emitted close to game time. Wired here as TODO so
|
||||
// the orchestrator just gets an empty array until we light it up.
|
||||
return [];
|
||||
}
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps, SPORT_IDS };
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* PrizePicks adapter — STUB.
|
||||
*
|
||||
* PrizePicks projections come from `api.prizepicks.com` (public). They have
|
||||
* one of the cleanest schemas of any DFS provider; if we light this up early
|
||||
* it gives us an excellent multi-source comparison signal.
|
||||
*
|
||||
* TODO: implement against /projections + /players + /leagues.
|
||||
*/
|
||||
|
||||
const SOURCE = 'prizepicks';
|
||||
|
||||
async function getGames(/* sport */) { return []; }
|
||||
async function getPlayerProps(/* sport */) { return []; }
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps };
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Rotowire daily projections adapter — STUB.
|
||||
*
|
||||
* Rotowire publishes daily lineup + projection pages per sport. Layout is
|
||||
* HTML-driven and changes seasonally; we'll lock in selectors against a
|
||||
* snapshot before flipping this on.
|
||||
*
|
||||
* Why it matters: when Rotowire's number agrees with VYNDR's projection
|
||||
* AND disagrees with the book line, model-stack confidence increases.
|
||||
*/
|
||||
|
||||
const SOURCE = 'rotowire';
|
||||
|
||||
async function getProjections(/* sport */) { return { sport: null, projections: [], note: 'not implemented' }; }
|
||||
|
||||
module.exports = { name: SOURCE, getProjections };
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* College Football Data (CFBD) — advanced college analytics.
|
||||
*
|
||||
* 100% free API key. Covers historical betting lines, player usage, team
|
||||
* talent composites, advanced efficiency (PPA), recruiting. nba_api
|
||||
* doesn't cover college; CFBD fills the gap for NCAAB and NCAAFB props.
|
||||
*
|
||||
* Note: CFBD's primary product is college football. College *basketball*
|
||||
* coverage is via the /cbb endpoints. We expose both via the sport-aware
|
||||
* functions below.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
|
||||
const SOURCE = 'cfbd';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_SECONDS = 6 * 60 * 60; // 6h — most CFBD data is daily-fresh
|
||||
|
||||
const BASE_URL = process.env.CFBD_BASE_URL || 'https://api.collegefootballdata.com';
|
||||
|
||||
// Generous free tier — 10 req/min keeps us well under documented limits.
|
||||
const limiter = createLimiter({ tokensPerInterval: 10, interval: 60_000 });
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
function configured() {
|
||||
return !!process.env.CFBD_KEY;
|
||||
}
|
||||
|
||||
async function fetchWithGuards(url, params, cacheKey) {
|
||||
if (!configured()) return null;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
await limiter.waitForToken();
|
||||
try {
|
||||
const data = await breaker.call(async () => {
|
||||
const res = await axios.get(url, {
|
||||
params,
|
||||
headers: { Authorization: `Bearer ${process.env.CFBD_KEY}` },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const err = new Error('cfbd rate limited');
|
||||
err.code = 'CFBD_429';
|
||||
throw err;
|
||||
}
|
||||
return res.data;
|
||||
});
|
||||
await cacheSet(cacheKey, data, CACHE_TTL_SECONDS);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err?.code === 'CIRCUIT_OPEN') return null;
|
||||
console.warn(`[${SOURCE}] fetch failed for ${cacheKey}:`, err?.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTeamStats(team, year) {
|
||||
const cacheKey = `cfbd:teamstats:${team}:${year}`;
|
||||
const data = await fetchWithGuards(`${BASE_URL}/stats/season`, { year, team }, cacheKey);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async function getPlayerUsage(player, team, year) {
|
||||
const cacheKey = `cfbd:usage:${player}:${team}:${year}`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/player/usage`,
|
||||
{ year, team, player },
|
||||
cacheKey
|
||||
);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async function getTalentComposite(team, year) {
|
||||
const cacheKey = `cfbd:talent:${team}:${year}`;
|
||||
const data = await fetchWithGuards(`${BASE_URL}/talent`, { year }, cacheKey);
|
||||
if (!Array.isArray(data)) return null;
|
||||
return data.find((row) => (row.school || row.team) === team) || null;
|
||||
}
|
||||
|
||||
async function getHistoricalLines(team, year) {
|
||||
const cacheKey = `cfbd:lines:${team}:${year}`;
|
||||
const data = await fetchWithGuards(`${BASE_URL}/lines`, { year, team }, cacheKey);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
getTeamStats,
|
||||
getPlayerUsage,
|
||||
getTalentComposite,
|
||||
getHistoricalLines,
|
||||
__internals: { limiter, breaker, BASE_URL },
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* OddsPapi — Pinnacle closing-line capture for CLV.
|
||||
*
|
||||
* Closing lines are immutable facts. Once captured at tip-off they live in
|
||||
* Supabase forever; we never re-read them, never cache them in Redis (would
|
||||
* be wasted space — they don't change).
|
||||
*
|
||||
* Called from the resolution poller the FIRST time it sees a game flip to
|
||||
* STATUS_IN_PROGRESS. One row per (game_id, player_espn_id, stat_type) via
|
||||
* the UNIQUE constraint in migration 016, so repeated triggers no-op
|
||||
* cleanly.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../utils/rateLimiter');
|
||||
const { devig } = require('../../utils/odds');
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const BASE_URL = process.env.ODDSPAPI_BASE_URL || 'https://api.oddspapi.io/v1';
|
||||
|
||||
const SPORT_KEYS = Object.freeze({
|
||||
nba: 'basketball_nba',
|
||||
wnba: 'basketball_wnba',
|
||||
mlb: 'baseball_mlb',
|
||||
nfl: 'americanfootball_nfl',
|
||||
nhl: 'icehockey_nhl',
|
||||
ncaab: 'basketball_ncaab',
|
||||
ncaafb: 'americanfootball_ncaaf',
|
||||
});
|
||||
|
||||
const limiter = createLimiter(API_BUDGETS.oddsPapi);
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
function configured() {
|
||||
return !!process.env.ODDSPAPI_KEY;
|
||||
}
|
||||
|
||||
function sportKey(sport) {
|
||||
const key = SPORT_KEYS[sport];
|
||||
if (!key) throw new Error(`Unsupported sport: ${sport}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function fetchPinnacleProp(sport, gameId, playerName, statType) {
|
||||
if (!configured()) return null;
|
||||
await limiter.waitForToken();
|
||||
try {
|
||||
return await breaker.call(async () => {
|
||||
const res = await axios.get(`${BASE_URL}/sports/${sportKey(sport)}/events/${gameId}/odds`, {
|
||||
params: { bookmaker: 'pinnacle', market: 'player_props' },
|
||||
headers: { 'X-Api-Key': process.env.ODDSPAPI_KEY },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
const props = res.data?.props || res.data?.data || [];
|
||||
return Array.isArray(props)
|
||||
? props.find(
|
||||
(p) =>
|
||||
(p.player ?? p.player_name)?.toLowerCase() === playerName.toLowerCase()
|
||||
&& (p.stat_type ?? p.market) === statType
|
||||
) || null
|
||||
: null;
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.code !== 'CIRCUIT_OPEN') {
|
||||
console.warn(`[oddspapi] fetch failed for ${sport}/${gameId}/${playerName}/${statType}:`, err?.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getPinnacleClosingLine(sport, gameId, playerEspnId, statType, playerName) {
|
||||
if (!configured()) return null;
|
||||
const prop = await fetchPinnacleProp(sport, gameId, playerName, statType);
|
||||
if (!prop) return null;
|
||||
const line = Number(prop.line ?? prop.point);
|
||||
const overOdds = Number(prop.over_price ?? prop.overOdds);
|
||||
const underOdds = Number(prop.under_price ?? prop.underOdds);
|
||||
if (!Number.isFinite(line) || !Number.isFinite(overOdds) || !Number.isFinite(underOdds)) return null;
|
||||
const fair = devig(overOdds, underOdds);
|
||||
return {
|
||||
line,
|
||||
overOdds,
|
||||
underOdds,
|
||||
fairOver: fair?.fairOver ?? null,
|
||||
fairUnder: fair?.fairUnder ?? null,
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function batchCapture(sport, gameId) {
|
||||
if (!configured()) return { captured: 0, skipped: 0, reason: 'not_configured' };
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
// Pull every unresolved prop for this game from the grading pipeline.
|
||||
// resolved_at IS NULL prevents double-capture for games we've already
|
||||
// processed (matters for retries from the poller).
|
||||
const { data: graded, error } = await supabase
|
||||
.from('grade_history')
|
||||
.select('player_id, player_name, stat_type')
|
||||
.eq('game_id', gameId)
|
||||
.is('resolved_at', null);
|
||||
|
||||
if (error) {
|
||||
console.warn('[oddspapi] grade_history lookup failed:', error.message);
|
||||
return { captured: 0, error: error.message };
|
||||
}
|
||||
if (!graded || graded.length === 0) {
|
||||
return { captured: 0, skipped: 0, reason: 'no_graded_props' };
|
||||
}
|
||||
|
||||
// Deduplicate by (player, stat) — same player can be graded twice on
|
||||
// different lines but we only need one Pinnacle reference per stat.
|
||||
const seen = new Set();
|
||||
const targets = [];
|
||||
for (const row of graded) {
|
||||
const key = `${row.player_id}|${row.stat_type}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
targets.push(row);
|
||||
}
|
||||
|
||||
let captured = 0;
|
||||
let skipped = 0;
|
||||
for (const t of targets) {
|
||||
const line = await getPinnacleClosingLine(sport, gameId, t.player_id, t.stat_type, t.player_name);
|
||||
if (!line) { skipped += 1; continue; }
|
||||
const { error: upsertErr } = await supabase
|
||||
.from('closing_lines')
|
||||
.upsert({
|
||||
game_id: gameId,
|
||||
sport,
|
||||
player_name: t.player_name,
|
||||
player_espn_id: t.player_id,
|
||||
stat_type: t.stat_type,
|
||||
pinnacle_line: line.line,
|
||||
pinnacle_over_odds: line.overOdds,
|
||||
pinnacle_under_odds: line.underOdds,
|
||||
fair_over_probability: line.fairOver,
|
||||
fair_under_probability: line.fairUnder,
|
||||
}, { onConflict: 'game_id,player_espn_id,stat_type' });
|
||||
if (upsertErr) {
|
||||
console.warn('[oddspapi] closing_lines upsert failed:', upsertErr.message);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
captured += 1;
|
||||
}
|
||||
return { captured, skipped, total: targets.length };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
getPinnacleClosingLine,
|
||||
batchCapture,
|
||||
__internals: { limiter, breaker, SPORT_KEYS },
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* OpenRouter — LLM inference adapter (Engine 2).
|
||||
*
|
||||
* Primary: DeepSeek V3 (deepseek/deepseek-chat) — best reasoning/dollar,
|
||||
* returns clean JSON when asked nicely.
|
||||
* Fallback: Nemotron (nvidia/llama-3.3-nemotron-super-49b-v1) — used when
|
||||
* primary 429s, 5xxs, or times out.
|
||||
*
|
||||
* SECURITY POSTURE:
|
||||
* - OPENROUTER_API_KEY is the most sensitive secret in this app. We
|
||||
* accept the key from env and pass it as a Bearer header — it never
|
||||
* appears in URLs, logs, or error messages we emit. Axios errors that
|
||||
* wrap the request are caught before re-throw to scrub headers.
|
||||
* - We do NOT include the string 'VYNDR' in prompts. OpenRouter is a
|
||||
* pass-through to third-party models and we don't want our brand
|
||||
* name in their training/QA pipelines.
|
||||
*
|
||||
* EXPORTS:
|
||||
* configured() → boolean
|
||||
* analyze(systemMessage, userPrompt) → { response, modelUsed, latencyMs }
|
||||
* or null on total failure
|
||||
* getUsage() → { requestsToday, requestsRemaining }
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||
|
||||
const SOURCE = 'openrouter';
|
||||
const BASE_URL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
|
||||
const HTTP_TIMEOUT_MS = 30_000;
|
||||
|
||||
const PRIMARY_MODEL = process.env.OPENROUTER_PRIMARY_MODEL || 'deepseek/deepseek-chat';
|
||||
const FALLBACK_MODEL = process.env.OPENROUTER_FALLBACK_MODEL || 'nvidia/llama-3.3-nemotron-super-49b-v1';
|
||||
|
||||
// 20 req/min, 1000/day. The day counter is in-memory; it resets on process
|
||||
// restart. That's good enough for free-tier accounting — we hit the cap
|
||||
// well before midnight in normal traffic patterns.
|
||||
const limiter = createLimiter({ tokensPerInterval: 20, interval: 60_000 });
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
const DAILY_CAP = 1000;
|
||||
const usage = { requestsToday: 0, dayBucket: new Date().toISOString().slice(0, 10) };
|
||||
|
||||
function noteUsage() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (today !== usage.dayBucket) {
|
||||
usage.dayBucket = today;
|
||||
usage.requestsToday = 0;
|
||||
}
|
||||
usage.requestsToday += 1;
|
||||
}
|
||||
|
||||
function configured() {
|
||||
return !!process.env.OPENROUTER_API_KEY;
|
||||
}
|
||||
|
||||
function getUsage() {
|
||||
return {
|
||||
requestsToday: usage.requestsToday,
|
||||
requestsRemaining: Math.max(0, DAILY_CAP - usage.requestsToday),
|
||||
};
|
||||
}
|
||||
|
||||
// Scrub axios errors before anything user-facing — the headers, request
|
||||
// body, and full URL may contain the key.
|
||||
function scrubError(err) {
|
||||
return {
|
||||
code: err?.code,
|
||||
status: err?.response?.status,
|
||||
message: err?.message || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
async function callModel(model, systemMessage, userPrompt) {
|
||||
const start = Date.now();
|
||||
const body = {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemMessage },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: 500,
|
||||
// response_format works on OpenAI-compatible endpoints; harmless if a
|
||||
// model ignores it. We still validate the response ourselves.
|
||||
response_format: { type: 'json_object' },
|
||||
};
|
||||
const res = await axios.post(`${BASE_URL}/chat/completions`, body, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
// OpenRouter recommends setting referer + title for usage tracking.
|
||||
// Neither contains 'VYNDR' branding — they're generic per their docs.
|
||||
'HTTP-Referer': process.env.OPENROUTER_REFERER || 'https://vyndr.app',
|
||||
'X-Title': process.env.OPENROUTER_TITLE || 'Sports Analytics',
|
||||
},
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 429 || (s >= 500 && s < 600),
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const err = new Error('openrouter rate limited');
|
||||
err.code = 'OPENROUTER_429';
|
||||
throw err;
|
||||
}
|
||||
if (res.status >= 500) {
|
||||
const err = new Error(`openrouter 5xx (${res.status})`);
|
||||
err.code = 'OPENROUTER_5XX';
|
||||
throw err;
|
||||
}
|
||||
const content = res.data?.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
const err = new Error('openrouter empty response');
|
||||
err.code = 'OPENROUTER_EMPTY';
|
||||
throw err;
|
||||
}
|
||||
return { response: content, modelUsed: model, latencyMs: Date.now() - start };
|
||||
}
|
||||
|
||||
async function analyze(systemMessage, userPrompt) {
|
||||
if (!configured()) return null;
|
||||
if (typeof systemMessage !== 'string' || typeof userPrompt !== 'string') return null;
|
||||
if (usage.requestsToday >= DAILY_CAP) {
|
||||
console.warn(`[${SOURCE}] daily cap reached (${DAILY_CAP})`);
|
||||
return null;
|
||||
}
|
||||
await limiter.waitForToken();
|
||||
|
||||
// Try primary; on failure, retry once with the fallback model.
|
||||
try {
|
||||
const result = await breaker.call(() => callModel(PRIMARY_MODEL, systemMessage, userPrompt));
|
||||
noteUsage();
|
||||
return result;
|
||||
} catch (primaryErr) {
|
||||
const scrubbed = scrubError(primaryErr);
|
||||
if (primaryErr?.code === 'CIRCUIT_OPEN') {
|
||||
// Don't burn the second model when the breaker says everything is down.
|
||||
return null;
|
||||
}
|
||||
console.warn(`[${SOURCE}] primary failed:`, scrubbed);
|
||||
try {
|
||||
// Fallback bypasses the breaker — different model, different upstream.
|
||||
const result = await callModel(FALLBACK_MODEL, systemMessage, userPrompt);
|
||||
noteUsage();
|
||||
return result;
|
||||
} catch (fallbackErr) {
|
||||
console.warn(`[${SOURCE}] fallback also failed:`, scrubError(fallbackErr));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
analyze,
|
||||
getUsage,
|
||||
__internals: { limiter, breaker, callModel, scrubError, PRIMARY_MODEL, FALLBACK_MODEL, usage },
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* ParlayAPI — historical prop archive.
|
||||
*
|
||||
* Free tier: 1,000 credits/month. 3.7M historical prop closing records,
|
||||
* 1.56M game-line archive. "Drop-in for the-odds-api, up to 6× cheaper."
|
||||
*
|
||||
* When called:
|
||||
* 1. Historical pull script (scripts/pull-parlayapi-history.js) — bulk
|
||||
* 2. Trap detection — query historical hit rates for a player/stat combo
|
||||
* 3. Feature enrichment — historical line accuracy
|
||||
*
|
||||
* NOT used during real-time grading (credit-limited).
|
||||
* Historical data lands in Supabase `historical_props` (migration 017).
|
||||
*
|
||||
* Failure modes mirror sharpApiAdapter:
|
||||
* - 429 → back off, no stale cache (historical data isn't time-sensitive)
|
||||
* - 5xx → circuit breaker (3 fails → open 60s)
|
||||
* - timeout → 10s, breaker counts it
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
|
||||
const SOURCE = 'parlayapi';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24h — historical data is immutable
|
||||
|
||||
const BASE_URL = process.env.PARLAYAPI_BASE_URL || 'https://api.parlayapi.io/v1';
|
||||
|
||||
// Conservative budget: 5 req/min lets us spread 1,000 credits/month across the
|
||||
// month (~33/day). Bulk script overrides with its own pacing.
|
||||
const limiter = createLimiter({ tokensPerInterval: 5, interval: 60_000 });
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
const SPORT_KEYS = Object.freeze({
|
||||
nba: 'basketball_nba',
|
||||
wnba: 'basketball_wnba',
|
||||
mlb: 'baseball_mlb',
|
||||
nfl: 'americanfootball_nfl',
|
||||
nhl: 'icehockey_nhl',
|
||||
ncaab: 'basketball_ncaab',
|
||||
ncaafb: 'americanfootball_ncaaf',
|
||||
});
|
||||
|
||||
function configured() {
|
||||
return !!process.env.PARLAYAPI_KEY;
|
||||
}
|
||||
|
||||
function sportKey(sport) {
|
||||
const key = SPORT_KEYS[sport];
|
||||
if (!key) throw new Error(`Unsupported sport: ${sport}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function fetchWithGuards(url, params, cacheKey) {
|
||||
if (!configured()) return null;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
await limiter.waitForToken();
|
||||
try {
|
||||
const data = await breaker.call(async () => {
|
||||
const res = await axios.get(url, {
|
||||
params,
|
||||
headers: { 'X-Api-Key': process.env.PARLAYAPI_KEY },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const err = new Error('parlayapi rate limited');
|
||||
err.code = 'PARLAYAPI_429';
|
||||
throw err;
|
||||
}
|
||||
return res.data;
|
||||
});
|
||||
await cacheSet(cacheKey, data, CACHE_TTL_SECONDS);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err?.code === 'CIRCUIT_OPEN') return null;
|
||||
console.warn(`[${SOURCE}] fetch failed for ${cacheKey}:`, err?.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHistoricalProp(raw, sport) {
|
||||
return {
|
||||
sport,
|
||||
game_date: raw.game_date ?? raw.date ?? null,
|
||||
player_name: raw.player ?? raw.player_name ?? null,
|
||||
stat_type: raw.stat_type ?? raw.market ?? null,
|
||||
line: Number(raw.line ?? raw.point ?? null),
|
||||
closing_line: Number(raw.closing_line ?? raw.close ?? null) || null,
|
||||
result: raw.result ?? raw.outcome ?? null,
|
||||
source: SOURCE,
|
||||
};
|
||||
}
|
||||
|
||||
async function getHistoricalProps(sport, playerName, statType, limit = 50) {
|
||||
const key = sportKey(sport);
|
||||
const cacheKey = `parlayapi:hist:${sport}:${playerName}:${statType}:${limit}`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/historical/player_props`,
|
||||
{ sport: key, player: playerName, stat_type: statType, limit },
|
||||
cacheKey
|
||||
);
|
||||
if (!data) return [];
|
||||
const raw = data.props || data.results || data.data || [];
|
||||
return Array.isArray(raw) ? raw.map((r) => normalizeHistoricalProp(r, sport)) : [];
|
||||
}
|
||||
|
||||
async function getClosingLines(sport, gameDate) {
|
||||
const key = sportKey(sport);
|
||||
const cacheKey = `parlayapi:close:${sport}:${gameDate}`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/historical/closing_lines`,
|
||||
{ sport: key, date: gameDate },
|
||||
cacheKey
|
||||
);
|
||||
if (!data) return [];
|
||||
const raw = data.lines || data.results || data.data || [];
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
getHistoricalProps,
|
||||
getClosingLines,
|
||||
__internals: { limiter, breaker, SPORT_KEYS, BASE_URL, normalizeHistoricalProp },
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* PropOdds — player-prop specialist. Consensus source #2 alongside SharpAPI.
|
||||
*
|
||||
* Strict free-tier monthly limits — use sparingly. Specialized for the exact
|
||||
* lane we live in: player props.
|
||||
*
|
||||
* When called: during grading, AFTER SharpAPI, to get a second consensus
|
||||
* data point. Three-way consensus (SharpAPI + PropOdds + OddsPapi) is a
|
||||
* stronger signal than two-way for the line-divergence trap.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
const { devig } = require('../../utils/odds');
|
||||
|
||||
const SOURCE = 'propodds';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_SECONDS = 90; // odds are time-sensitive but rarer fetch
|
||||
const STALE_CACHE_TTL_SECONDS = 300;
|
||||
|
||||
const BASE_URL = process.env.PROPODDS_BASE_URL || 'https://api.prop-odds.com/v1';
|
||||
|
||||
// 3 req/min — strict because the free monthly cap is low.
|
||||
const limiter = createLimiter({ tokensPerInterval: 3, interval: 60_000 });
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
const SPORT_KEYS = Object.freeze({
|
||||
nba: 'nba',
|
||||
wnba: 'wnba',
|
||||
mlb: 'mlb',
|
||||
nfl: 'nfl',
|
||||
nhl: 'nhl',
|
||||
ncaab: 'ncaab',
|
||||
ncaafb: 'ncaaf',
|
||||
});
|
||||
|
||||
function configured() {
|
||||
return !!process.env.PROPODDS_KEY;
|
||||
}
|
||||
|
||||
function sportKey(sport) {
|
||||
const key = SPORT_KEYS[sport];
|
||||
if (!key) throw new Error(`Unsupported sport: ${sport}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function fetchWithGuards(url, params, cacheKey) {
|
||||
if (!configured()) return null;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached && !cached.stale) return cached;
|
||||
|
||||
await limiter.waitForToken();
|
||||
try {
|
||||
const data = await breaker.call(async () => {
|
||||
const res = await axios.get(url, {
|
||||
params: { ...params, api_key: process.env.PROPODDS_KEY },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const err = new Error('propodds rate limited');
|
||||
err.code = 'PROPODDS_429';
|
||||
throw err;
|
||||
}
|
||||
return res.data;
|
||||
});
|
||||
await cacheSet(cacheKey, data, CACHE_TTL_SECONDS);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err?.code === 'PROPODDS_429' && cached) {
|
||||
const stale = { ...cached, stale: true };
|
||||
await cacheSet(cacheKey, stale, STALE_CACHE_TTL_SECONDS);
|
||||
return stale;
|
||||
}
|
||||
if (err?.code === 'CIRCUIT_OPEN') return cached ? { ...cached, stale: true } : null;
|
||||
console.warn(`[${SOURCE}] fetch failed for ${cacheKey}:`, err?.message);
|
||||
return cached ? { ...cached, stale: true } : null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProp(raw) {
|
||||
const overOdds = raw.over_odds ?? raw.over_price ?? null;
|
||||
const underOdds = raw.under_odds ?? raw.under_price ?? null;
|
||||
const fair = (overOdds != null && underOdds != null) ? devig(overOdds, underOdds) : null;
|
||||
return {
|
||||
book: raw.book ?? raw.bookmaker ?? null,
|
||||
player: raw.player ?? raw.player_name ?? null,
|
||||
statType: raw.market ?? raw.stat_type ?? null,
|
||||
line: Number(raw.line ?? raw.point ?? null),
|
||||
overOdds,
|
||||
underOdds,
|
||||
fairOver: fair?.fairOver ?? null,
|
||||
fairUnder: fair?.fairUnder ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getPlayerProps(sport, gameId, player, statType) {
|
||||
const key = sportKey(sport);
|
||||
const cacheKey = `propodds:${sport}:${gameId}:${player || 'all'}:${statType || 'all'}`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/sports/${key}/games/${gameId}/odds`,
|
||||
{ player, market: statType },
|
||||
cacheKey
|
||||
);
|
||||
if (!data) return [];
|
||||
const raw = data.props || data.markets || data.data || [];
|
||||
const normalized = Array.isArray(raw) ? raw.map(normalizeProp) : [];
|
||||
return data.stale ? Object.assign(normalized, { stale: true }) : normalized;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
getPlayerProps,
|
||||
__internals: { limiter, breaker, SPORT_KEYS, BASE_URL, normalizeProp },
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* SharpAPI — PRIMARY real-time odds source.
|
||||
*
|
||||
* Called by the GRADING PIPELINE (n8n) before a game tips. NOT used by the
|
||||
* resolution poller — closing-line capture goes through OddsPapi for the
|
||||
* Pinnacle benchmark.
|
||||
*
|
||||
* The adapter exposes three reads:
|
||||
* getPlayerProps — every player prop across books, de-vigged
|
||||
* getGameOdds — spread / total / moneyline for one game
|
||||
* getConsensusLine — median / min / max line across books (trap detector)
|
||||
*
|
||||
* Free-tier budget is 12 req/min. We cap at 10 to leave headroom for
|
||||
* incident retries. Responses cache in Redis for 60s — long enough to
|
||||
* coalesce duplicate grade requests for the same prop, short enough that a
|
||||
* line move propagates inside a minute.
|
||||
*
|
||||
* Failure modes:
|
||||
* 429 — back off, serve stale cache marked `{ stale: true }`
|
||||
* 5xx — circuit breaker (3 fails → open 60s)
|
||||
* timeout — 10s connect/read, circuit breaker counts it as a failure
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../utils/rateLimiter');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
const { devig } = require('../../utils/odds');
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
|
||||
const SOURCE = 'sharpapi';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_SECONDS = 60;
|
||||
const STALE_CACHE_TTL_SECONDS = 300;
|
||||
|
||||
const BASE_URL = process.env.SHARPAPI_BASE_URL || 'https://api.sharpapi.com/v1';
|
||||
|
||||
const SPORT_KEYS = Object.freeze({
|
||||
nba: 'basketball_nba',
|
||||
wnba: 'basketball_wnba',
|
||||
mlb: 'baseball_mlb',
|
||||
nfl: 'americanfootball_nfl',
|
||||
nhl: 'icehockey_nhl',
|
||||
ncaab: 'basketball_ncaab',
|
||||
ncaafb: 'americanfootball_ncaaf',
|
||||
});
|
||||
|
||||
const limiter = createLimiter(API_BUDGETS.sharpApi);
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
function configured() {
|
||||
return !!process.env.SHARPAPI_KEY;
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return { 'X-Api-Key': process.env.SHARPAPI_KEY };
|
||||
}
|
||||
|
||||
function sportKey(sport) {
|
||||
const key = SPORT_KEYS[sport];
|
||||
if (!key) throw new Error(`Unsupported sport: ${sport}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
function median(nums) {
|
||||
if (!nums.length) return null;
|
||||
const sorted = [...nums].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
async function fetchWithGuards(url, params, cacheKey) {
|
||||
if (!configured()) return null;
|
||||
|
||||
// 1. Hot cache — fresh hit returns immediately.
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached && !cached.stale) return cached;
|
||||
|
||||
await limiter.waitForToken();
|
||||
|
||||
try {
|
||||
const data = await breaker.call(async () => {
|
||||
const res = await axios.get(url, {
|
||||
params,
|
||||
headers: authHeaders(),
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const err = new Error('sharpapi rate limited');
|
||||
err.code = 'SHARPAPI_429';
|
||||
throw err;
|
||||
}
|
||||
return res.data;
|
||||
});
|
||||
await cacheSet(cacheKey, data, CACHE_TTL_SECONDS);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err?.code === 'SHARPAPI_429' && cached) {
|
||||
// Serve stale cache marked so callers can decide whether to trust it.
|
||||
const stale = { ...cached, stale: true };
|
||||
await cacheSet(cacheKey, stale, STALE_CACHE_TTL_SECONDS);
|
||||
return stale;
|
||||
}
|
||||
if (err?.code === 'CIRCUIT_OPEN') {
|
||||
// Don't spam logs while the breaker is open — one warn per minute is
|
||||
// enough; the snapshot tells ops the state.
|
||||
return cached ? { ...cached, stale: true } : null;
|
||||
}
|
||||
console.warn(`[sharpapi] fetch failed for ${cacheKey}:`, err?.message);
|
||||
return cached ? { ...cached, stale: true } : null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlayerProp(raw) {
|
||||
// Defensive shape — SharpAPI returns slightly different field names across
|
||||
// markets. We only surface the fields downstream consumers actually need.
|
||||
const overOdds = raw.over_price ?? raw.overOdds ?? null;
|
||||
const underOdds = raw.under_price ?? raw.underOdds ?? null;
|
||||
const fair = (overOdds != null && underOdds != null) ? devig(overOdds, underOdds) : null;
|
||||
return {
|
||||
book: raw.book ?? raw.bookmaker ?? null,
|
||||
player: raw.player ?? raw.player_name ?? null,
|
||||
statType: raw.stat_type ?? raw.market ?? null,
|
||||
line: Number(raw.line ?? raw.point ?? null),
|
||||
overOdds,
|
||||
underOdds,
|
||||
fairOver: fair?.fairOver ?? null,
|
||||
fairUnder: fair?.fairUnder ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Fire-and-forget snapshot writer. Persists each line we observe so we can
|
||||
// later detect reverse line movement + juice degradation. Never blocks the
|
||||
// caller — a Supabase outage must not stop the grading pipeline.
|
||||
function snapshotProps(sport, gameId, normalized, consensusMedian) {
|
||||
if (!normalized || normalized.length === 0) return;
|
||||
const rows = normalized
|
||||
.filter((p) => Number.isFinite(p.line))
|
||||
.map((p) => ({
|
||||
game_id: gameId,
|
||||
sport,
|
||||
player_name: p.player,
|
||||
player_id: null,
|
||||
stat_type: p.statType,
|
||||
line: p.line,
|
||||
over_odds: p.overOdds,
|
||||
under_odds: p.underOdds,
|
||||
book: p.book,
|
||||
consensus_median: consensusMedian ?? null,
|
||||
}));
|
||||
if (rows.length === 0) return;
|
||||
// Run after the response — Promise.resolve().then keeps it off the
|
||||
// caller's critical path without leaking unhandled rejections.
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { error } = await supabase.from('line_snapshots').insert(rows);
|
||||
if (error) console.warn('[sharpapi] snapshot insert failed:', error.message);
|
||||
} catch (err) {
|
||||
console.warn('[sharpapi] snapshot insert threw:', err?.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getPlayerProps(sport, gameId) {
|
||||
const key = sportKey(sport);
|
||||
const cacheKey = `odds:${sport}:${gameId}:player_props`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/sports/${key}/events/${gameId}/odds`,
|
||||
{ markets: 'player_props' },
|
||||
cacheKey
|
||||
);
|
||||
if (!data) return [];
|
||||
const raw = data.props || data.markets || data.data || [];
|
||||
const normalized = Array.isArray(raw) ? raw.map(normalizePlayerProp) : [];
|
||||
|
||||
// Only snapshot fresh data — stale-cache fallbacks are previously stored
|
||||
// already; re-snapshotting would mint duplicate "now" rows on every call.
|
||||
if (!data.stale && normalized.length) {
|
||||
snapshotProps(sport, gameId, normalized);
|
||||
}
|
||||
|
||||
return data.stale ? Object.assign(normalized, { stale: true }) : normalized;
|
||||
}
|
||||
|
||||
async function getGameOdds(sport, gameId) {
|
||||
const key = sportKey(sport);
|
||||
const cacheKey = `odds:${sport}:${gameId}:game`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/sports/${key}/events/${gameId}/odds`,
|
||||
{ markets: 'spreads,totals,h2h' },
|
||||
cacheKey
|
||||
);
|
||||
if (!data) return null;
|
||||
return {
|
||||
spread: data.spread ?? null,
|
||||
total: data.total ?? null,
|
||||
moneyline: data.h2h ?? data.moneyline ?? null,
|
||||
stale: !!data.stale,
|
||||
};
|
||||
}
|
||||
|
||||
async function getConsensusLine(sport, gameId, playerName, statType) {
|
||||
const props = await getPlayerProps(sport, gameId);
|
||||
const matches = props.filter(
|
||||
(p) => p.player && p.statType
|
||||
&& p.player.toLowerCase() === playerName.toLowerCase()
|
||||
&& p.statType === statType
|
||||
&& Number.isFinite(p.line)
|
||||
);
|
||||
if (!matches.length) return null;
|
||||
const lines = matches.map((p) => p.line);
|
||||
return {
|
||||
median: median(lines),
|
||||
min: Math.min(...lines),
|
||||
max: Math.max(...lines),
|
||||
bookCount: matches.length,
|
||||
stale: !!props.stale,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
getPlayerProps,
|
||||
getGameOdds,
|
||||
getConsensusLine,
|
||||
// Exported for tests so they can poke the circuit breaker / limiter state.
|
||||
__internals: { limiter, breaker, SPORT_KEYS },
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Normal CDF using rational approximation (Abramowitz & Stegun).
|
||||
*/
|
||||
function normalCDF(x, mean = 0, stddev = 1) {
|
||||
if (stddev <= 0) return x >= mean ? 1 : 0;
|
||||
const z = (x - mean) / stddev;
|
||||
const t = 1 / (1 + 0.2316419 * Math.abs(z));
|
||||
const d = 0.3989422804014327; // 1/sqrt(2*pi)
|
||||
const p = d * Math.exp(-z * z / 2) *
|
||||
(t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.8212560 + t * 1.3302744)))));
|
||||
return z > 0 ? 1 - p : p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate model probability for a prop line using normal distribution.
|
||||
* @param {number} mean - Projected mean
|
||||
* @param {number} stddev - Standard deviation
|
||||
* @param {number} line - The prop line
|
||||
* @param {string} direction - 'over' or 'under'
|
||||
* @returns {number} Probability 0-1
|
||||
*/
|
||||
function calculateModelProbability(mean, stddev, line, direction) {
|
||||
if (stddev <= 0) {
|
||||
if (direction === 'over') return mean > line ? 1 : 0;
|
||||
return mean < line ? 1 : 0;
|
||||
}
|
||||
|
||||
const cdf = normalCDF(line, mean, stddev);
|
||||
return direction === 'over' ? 1 - cdf : cdf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert American odds to implied probability.
|
||||
* @param {number} odds - American odds (e.g. -110, +150)
|
||||
* @returns {number} Implied probability 0-1
|
||||
*/
|
||||
function americanToImplied(odds) {
|
||||
if (odds < 0) return Math.abs(odds) / (Math.abs(odds) + 100);
|
||||
return 100 / (odds + 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare model probability to book implied probability.
|
||||
* @param {number} modelProb - Model-calculated probability
|
||||
* @param {number} bookOdds - American odds from the book
|
||||
* @returns {object} { model_prob, book_implied, edge, value_detected }
|
||||
*/
|
||||
function compareToBookImplied(modelProb, bookOdds) {
|
||||
const bookImplied = americanToImplied(bookOdds);
|
||||
const edge = modelProb - bookImplied;
|
||||
|
||||
return {
|
||||
model_prob: Math.round(modelProb * 1000) / 1000,
|
||||
book_implied: Math.round(bookImplied * 1000) / 1000,
|
||||
edge: Math.round(edge * 1000) / 1000,
|
||||
value_detected: edge > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan alternate lines for A-grade props to find optimal value.
|
||||
* @param {object} prop - { player, stat, projected_mean, projected_stddev, grade }
|
||||
* @param {Array} oddsData - Array of { line, odds, book } from alt markets
|
||||
* @returns {object|null} Best alt line with edge, or null
|
||||
*/
|
||||
function scanAltLines(prop, oddsData) {
|
||||
if (!prop || !oddsData || oddsData.length === 0) return null;
|
||||
|
||||
const { projected_mean, projected_stddev } = prop;
|
||||
const direction = prop.direction || 'over';
|
||||
|
||||
const evaluated = oddsData.map(alt => {
|
||||
const modelProb = calculateModelProbability(projected_mean, projected_stddev, alt.line, direction);
|
||||
const comparison = compareToBookImplied(modelProb, alt.odds);
|
||||
|
||||
return {
|
||||
line: alt.line,
|
||||
odds: alt.odds,
|
||||
book: alt.book,
|
||||
model_probability: comparison.model_prob,
|
||||
book_implied: comparison.book_implied,
|
||||
edge: comparison.edge,
|
||||
value_detected: comparison.value_detected,
|
||||
};
|
||||
});
|
||||
|
||||
const withValue = evaluated.filter(e => e.value_detected);
|
||||
if (withValue.length === 0) return null;
|
||||
|
||||
withValue.sort((a, b) => b.edge - a.edge);
|
||||
const optimal = withValue[0];
|
||||
|
||||
return {
|
||||
optimal_line: optimal.line,
|
||||
odds: optimal.odds,
|
||||
book: optimal.book,
|
||||
model_probability: optimal.model_probability,
|
||||
book_implied: optimal.book_implied,
|
||||
edge: optimal.edge,
|
||||
all_value_lines: withValue,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scanAltLines,
|
||||
calculateModelProbability,
|
||||
compareToBookImplied,
|
||||
normalCDF,
|
||||
americanToImplied,
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
const DISTRIBUTION_SHAPES = {
|
||||
points: 'normal',
|
||||
rebounds: 'normal',
|
||||
assists: 'normal',
|
||||
home_runs: 'negative_binomial',
|
||||
stolen_bases: 'negative_binomial',
|
||||
pitcher_strikeouts: 'bimodal_mixture',
|
||||
walks: 'poisson',
|
||||
hits: 'normal',
|
||||
total_bases: 'normal',
|
||||
rbis: 'normal',
|
||||
runs_scored: 'poisson',
|
||||
strikeouts_batter: 'poisson',
|
||||
earned_runs: 'poisson',
|
||||
outs_recorded: 'normal',
|
||||
walks_allowed: 'poisson',
|
||||
hits_allowed: 'normal',
|
||||
pitches_thrown: 'normal',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the distribution shape for a stat type.
|
||||
* @param {string} statType
|
||||
* @returns {string} Distribution shape name
|
||||
*/
|
||||
function getDistributionShape(statType) {
|
||||
return DISTRIBUTION_SHAPES[statType] || 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normal CDF using rational approximation.
|
||||
*/
|
||||
function normalCDF(x, mean, stddev) {
|
||||
if (stddev <= 0) return x >= mean ? 1 : 0;
|
||||
const z = (x - mean) / stddev;
|
||||
const t = 1 / (1 + 0.2316419 * Math.abs(z));
|
||||
const d = 0.3989422804014327;
|
||||
const p = d * Math.exp(-z * z / 2) *
|
||||
(t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.8212560 + t * 1.3302744)))));
|
||||
return z > 0 ? 1 - p : p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poisson CDF: P(X <= x) for Poisson(lambda).
|
||||
* @param {number} x - Value (floored to integer)
|
||||
* @param {number} lambda - Rate parameter
|
||||
* @returns {number} Cumulative probability
|
||||
*/
|
||||
function poissonCDF(x, lambda) {
|
||||
if (lambda <= 0) return 1;
|
||||
const k = Math.floor(x);
|
||||
if (k < 0) return 0;
|
||||
|
||||
let cdf = 0;
|
||||
let term = Math.exp(-lambda);
|
||||
cdf += term;
|
||||
|
||||
for (let i = 1; i <= k; i++) {
|
||||
term *= lambda / i;
|
||||
cdf += term;
|
||||
}
|
||||
|
||||
return Math.min(1, cdf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative Binomial CDF: P(X <= x) for NB(r, p).
|
||||
* Uses direct summation of PMF.
|
||||
* @param {number} x - Value (floored to integer)
|
||||
* @param {number} r - Number of successes
|
||||
* @param {number} p - Probability of success per trial
|
||||
* @returns {number} Cumulative probability
|
||||
*/
|
||||
function negativeBinomialCDF(x, r, p) {
|
||||
if (r <= 0 || p <= 0 || p > 1) return 0;
|
||||
const k = Math.floor(x);
|
||||
if (k < 0) return 0;
|
||||
|
||||
let cdf = 0;
|
||||
|
||||
// log of binomial coefficient using lgamma approximation
|
||||
function logGamma(z) {
|
||||
// Stirling approximation for lgamma
|
||||
if (z < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * z)) - logGamma(1 - z);
|
||||
z -= 1;
|
||||
const coeffs = [
|
||||
76.18009172947146, -86.50532032941677, 24.01409824083091,
|
||||
-1.231739572450155, 0.001208650973866179, -0.000005395239384953,
|
||||
];
|
||||
let x = 0.99999999999980993;
|
||||
for (let i = 0; i < coeffs.length; i++) {
|
||||
x += coeffs[i] / (z + i + 1);
|
||||
}
|
||||
const t = z + coeffs.length - 0.5;
|
||||
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
|
||||
}
|
||||
|
||||
for (let i = 0; i <= k; i++) {
|
||||
const logCoeff = logGamma(i + r) - logGamma(i + 1) - logGamma(r);
|
||||
const logProb = logCoeff + r * Math.log(p) + i * Math.log(1 - p);
|
||||
cdf += Math.exp(logProb);
|
||||
}
|
||||
|
||||
return Math.min(1, Math.max(0, cdf));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate probability based on distribution shape.
|
||||
* @param {string} shape - Distribution type
|
||||
* @param {object} params - Distribution parameters
|
||||
* @param {number} line - Prop line
|
||||
* @param {string} direction - 'over' or 'under'
|
||||
* @returns {number} Probability 0-1
|
||||
*/
|
||||
function calculateProbability(shape, params, line, direction) {
|
||||
let cdf;
|
||||
|
||||
switch (shape) {
|
||||
case 'normal':
|
||||
cdf = normalCDF(line, params.mean, params.stddev);
|
||||
break;
|
||||
case 'poisson':
|
||||
cdf = poissonCDF(line, params.lambda);
|
||||
break;
|
||||
case 'negative_binomial':
|
||||
cdf = negativeBinomialCDF(line, params.r, params.p);
|
||||
break;
|
||||
case 'bimodal_mixture':
|
||||
// Weighted mixture of two normals
|
||||
const w1 = params.weight1 || 0.5;
|
||||
const cdf1 = normalCDF(line, params.mean1, params.stddev1);
|
||||
const cdf2 = normalCDF(line, params.mean2, params.stddev2);
|
||||
cdf = w1 * cdf1 + (1 - w1) * cdf2;
|
||||
break;
|
||||
default:
|
||||
cdf = normalCDF(line, params.mean, params.stddev);
|
||||
}
|
||||
|
||||
return direction === 'over' ? 1 - cdf : cdf;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DISTRIBUTION_SHAPES,
|
||||
getDistributionShape,
|
||||
calculateProbability,
|
||||
normalCDF,
|
||||
poissonCDF,
|
||||
negativeBinomialCDF,
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Per-upstream circuit breaker.
|
||||
*
|
||||
* States:
|
||||
* CLOSED — calls flow normally
|
||||
* OPEN — calls short-circuit instantly with BreakerOpenError
|
||||
* HALF_OPEN — one trial call allowed; success closes, failure re-opens
|
||||
*
|
||||
* Tunable per key; defaults are conservative (open after 5 fails in 60s,
|
||||
* stay open for 30s). Set per-source thresholds in OVERRIDES.
|
||||
*/
|
||||
|
||||
const STATES = Object.freeze({ CLOSED: 'CLOSED', OPEN: 'OPEN', HALF_OPEN: 'HALF_OPEN' });
|
||||
|
||||
const DEFAULTS = {
|
||||
failureThreshold: 5,
|
||||
windowMs: 60_000,
|
||||
cooldownMs: 30_000,
|
||||
};
|
||||
|
||||
const OVERRIDES = Object.freeze({
|
||||
pinnacle: { failureThreshold: 3, cooldownMs: 60_000 },
|
||||
'nba-stats': { failureThreshold: 4, cooldownMs: 45_000 },
|
||||
pybaseball: { failureThreshold: 3, cooldownMs: 60_000 },
|
||||
});
|
||||
|
||||
class BreakerOpenError extends Error {
|
||||
constructor(key, retryAt) {
|
||||
super(`circuit open for ${key}`);
|
||||
this.name = 'BreakerOpenError';
|
||||
this.code = 'BREAKER_OPEN';
|
||||
this.upstream = key;
|
||||
this.retryAt = retryAt;
|
||||
}
|
||||
}
|
||||
|
||||
const breakers = new Map();
|
||||
|
||||
function getBreaker(key) {
|
||||
let b = breakers.get(key);
|
||||
if (!b) {
|
||||
const cfg = { ...DEFAULTS, ...(OVERRIDES[key] || {}) };
|
||||
b = {
|
||||
key,
|
||||
state: STATES.CLOSED,
|
||||
failures: [],
|
||||
openedAt: 0,
|
||||
cfg,
|
||||
};
|
||||
breakers.set(key, b);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function pruneFailures(b, now) {
|
||||
const cutoff = now - b.cfg.windowMs;
|
||||
while (b.failures.length && b.failures[0] < cutoff) b.failures.shift();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` under the breaker. If the breaker is OPEN, throws immediately.
|
||||
* Counts thrown errors as failures, except those marked `err.skipBreaker`.
|
||||
*/
|
||||
async function call(key, fn) {
|
||||
const b = getBreaker(key);
|
||||
const now = Date.now();
|
||||
|
||||
if (b.state === STATES.OPEN) {
|
||||
const reopenAt = b.openedAt + b.cfg.cooldownMs;
|
||||
if (now < reopenAt) throw new BreakerOpenError(key, reopenAt);
|
||||
// Cooldown elapsed — give one trial call.
|
||||
b.state = STATES.HALF_OPEN;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
if (b.state === STATES.HALF_OPEN) {
|
||||
b.state = STATES.CLOSED;
|
||||
b.failures.length = 0;
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (!err || !err.skipBreaker) {
|
||||
b.failures.push(Date.now());
|
||||
pruneFailures(b, Date.now());
|
||||
if (b.state === STATES.HALF_OPEN || b.failures.length >= b.cfg.failureThreshold) {
|
||||
b.state = STATES.OPEN;
|
||||
b.openedAt = Date.now();
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const out = {};
|
||||
for (const [k, b] of breakers.entries()) {
|
||||
pruneFailures(b, Date.now());
|
||||
out[k] = {
|
||||
state: b.state,
|
||||
failures: b.failures.length,
|
||||
cooldownEndsAt: b.state === STATES.OPEN ? b.openedAt + b.cfg.cooldownMs : null,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function reset(key) {
|
||||
if (key) breakers.delete(key);
|
||||
else breakers.clear();
|
||||
}
|
||||
|
||||
module.exports = { call, snapshot, reset, BreakerOpenError, STATES };
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Thin client for the local share-card endpoint.
|
||||
*
|
||||
* In-process callers can just call the renderer directly; we hit the route
|
||||
* so caching + rate-limit semantics stay consistent with how external
|
||||
* channels (n8n, email) will request the same cards.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
const API_BASE = process.env.SHARE_CARD_API_BASE || process.env.API_BASE_URL || 'http://localhost:4000';
|
||||
const TIMEOUT_MS = 12_000;
|
||||
|
||||
async function buildCard({ type, format = 'twitter', payload, raw = false }) {
|
||||
const res = await axios.post(`${API_BASE}/api/share-card${raw ? '?svg=1' : ''}`, { type, format, ...payload }, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: TIMEOUT_MS,
|
||||
validateStatus: (s) => s >= 200 && s < 500,
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
const err = new Error(`share-card responded ${res.status}`);
|
||||
err.detail = res.data?.toString?.('utf8');
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
buffer: Buffer.from(res.data),
|
||||
contentType: res.headers['content-type'] || 'image/png',
|
||||
cached: res.headers['x-cache'] === 'HIT',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a public URL where a card lives (or will be once the static-CDN
|
||||
* adapter is wired). Today the card is consumed directly by Telegram /
|
||||
* Discord / email so we don't always need a hosted URL — but generators
|
||||
* return one so downstream callers can render `<img src=...>` markup.
|
||||
*/
|
||||
function publicCardUrl({ type, format = 'twitter', payload }) {
|
||||
const qs = new URLSearchParams({
|
||||
type, format,
|
||||
p: Buffer.from(JSON.stringify(payload)).toString('base64url'),
|
||||
}).toString();
|
||||
const base = process.env.PUBLIC_SHARE_CARD_BASE || `${API_BASE}/api/share-card`;
|
||||
return `${base}?${qs}`;
|
||||
}
|
||||
|
||||
module.exports = { buildCard, publicCardUrl };
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Cascade alert formatter.
|
||||
*
|
||||
* Triggered by CascadeEngine when an injury / lineup / weather delta
|
||||
* recomputes a slate of grades. Composes the broadcast text + image
|
||||
* downstream channels send.
|
||||
*/
|
||||
|
||||
const { buildCard, publicCardUrl } = require('./_shareCardClient');
|
||||
|
||||
const EMOJI_BY_TRIGGER = Object.freeze({
|
||||
injury: '🚨',
|
||||
lineup: '🔁',
|
||||
weather: '☔',
|
||||
ref: '🟨',
|
||||
umpire: '🟨',
|
||||
manual: '🟢',
|
||||
});
|
||||
|
||||
function urgencyFor(affectedCount) {
|
||||
if (affectedCount >= 5) return 'high';
|
||||
if (affectedCount >= 2) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function shortTrigger(detail) {
|
||||
// Best-effort headline: prefer player + status if present, otherwise
|
||||
// type-specific defaults. Keep under 60 chars for the subject line.
|
||||
if (!detail || typeof detail !== 'object') return 'event';
|
||||
if (detail.player && detail.status) return `${detail.player} ${detail.status}`;
|
||||
if (detail.player) return detail.player;
|
||||
if (detail.summary) return String(detail.summary).slice(0, 60);
|
||||
return detail.type || 'event';
|
||||
}
|
||||
|
||||
async function format(alert) {
|
||||
const triggerType = alert.trigger_type || 'manual';
|
||||
const emoji = EMOJI_BY_TRIGGER[triggerType] || '🟢';
|
||||
const affected = Array.isArray(alert.affected_props) ? alert.affected_props : [];
|
||||
const count = affected.length;
|
||||
const headline = shortTrigger(alert.trigger_detail);
|
||||
|
||||
const text = `${emoji} ${headline.toUpperCase()} — ${count} prop${count === 1 ? '' : 's'} affected`;
|
||||
|
||||
// Render the cascade as a "recap" card so the layout is consistent.
|
||||
// Negative deltas (grade went down) render as miss-tinted rows, positives
|
||||
// as hit-tinted. This is purely visual; the data layer keeps the deltas.
|
||||
const entries = affected.slice(0, 6).map((p) => ({
|
||||
player: p.player || '',
|
||||
stat: p.stat || '',
|
||||
direction: p.direction || 'over',
|
||||
line: p.line,
|
||||
grade: p.new_grade || p.grade || '—',
|
||||
result: (p.new_grade && p.old_grade && rank(p.new_grade) > rank(p.old_grade)) ? 'miss' : 'hit',
|
||||
}));
|
||||
|
||||
const payload = {
|
||||
date: `${emoji} ${headline}`.slice(0, 40),
|
||||
accuracy: null,
|
||||
entries,
|
||||
};
|
||||
|
||||
let card = null;
|
||||
try {
|
||||
card = await buildCard({ type: 'recap', format: 'square', payload });
|
||||
} catch (err) {
|
||||
console.error('[cascadeFormatter] card build failed:', err.message);
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
urgency: urgencyFor(count),
|
||||
affected_count: count,
|
||||
imageBuffer: card?.buffer || null,
|
||||
imageUrl: publicCardUrl({ type: 'recap', format: 'square', payload }),
|
||||
};
|
||||
}
|
||||
|
||||
function rank(grade) {
|
||||
const map = { 'A+': 0, 'A': 1, 'A-': 2, 'B+': 3, 'B': 4, 'B-': 5, 'C+': 6, 'C': 7, 'C-': 8, 'D': 9, 'F': 10 };
|
||||
return map[grade] ?? 5;
|
||||
}
|
||||
|
||||
module.exports = { format, urgencyFor };
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Cheatsheet generator — runs daily ~4:30 PM ET via n8n.
|
||||
*
|
||||
* 1. Pull top-graded props for tonight (default limit 8).
|
||||
* 2. Synthesize structured cheatsheet payload.
|
||||
* 3. Build a share card via the local share-card endpoint.
|
||||
* 4. Return { data, imageUrl, imageBuffer } so downstream consumers
|
||||
* (email, telegram, discord) can fan out.
|
||||
*
|
||||
* No I/O side effects. Caller decides what to push where.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { buildCard, publicCardUrl } = require('./_shareCardClient');
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost:4000';
|
||||
|
||||
async function fetchTopGraded(limit = 8, sport = null) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (sport) params.set('sport', sport);
|
||||
const res = await axios.get(`${API_BASE}/api/props/top-graded?${params}`, { timeout: 10_000 });
|
||||
return Array.isArray(res.data?.props) ? res.data.props : [];
|
||||
}
|
||||
|
||||
function todayLabel(now = new Date()) {
|
||||
return now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'America/New_York' });
|
||||
}
|
||||
|
||||
async function generate({ limit = 8, sport = null, format = 'square' } = {}) {
|
||||
const grades = await fetchTopGraded(limit, sport);
|
||||
const payload = {
|
||||
date: todayLabel(),
|
||||
gameCount: new Set(grades.map((g) => g.game_id)).size,
|
||||
grades: grades.map((g) => ({
|
||||
grade: g.grade,
|
||||
player: g.player_name || g.player,
|
||||
stat: g.stat_type || g.stat,
|
||||
direction: g.direction,
|
||||
line: g.line,
|
||||
})),
|
||||
};
|
||||
|
||||
let card = null;
|
||||
try {
|
||||
card = await buildCard({ type: 'cheatsheet', format, payload });
|
||||
} catch (err) {
|
||||
// Don't sink the generator if the card can't render — surface the data,
|
||||
// let the caller decide whether to push text-only.
|
||||
console.error('[cheatsheetGenerator] card build failed:', err.message);
|
||||
}
|
||||
|
||||
return {
|
||||
data: payload,
|
||||
imageBuffer: card?.buffer || null,
|
||||
imageContentType: card?.contentType || null,
|
||||
imageUrl: publicCardUrl({ type: 'cheatsheet', format, payload }),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Grade of the Day selector — runs daily ~5:15 PM ET.
|
||||
*
|
||||
* Rules:
|
||||
* 1. Use an A+ if one exists tonight.
|
||||
* 2. Otherwise the single highest-confidence A or A-.
|
||||
* 3. Otherwise fall back to the top grade overall.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { buildCard, publicCardUrl } = require('./_shareCardClient');
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost:4000';
|
||||
const GRADE_RANK = { 'A+': 0, 'A': 1, 'A-': 2, 'B+': 3, 'B': 4, 'B-': 5, 'C+': 6, 'C': 7, 'C-': 8, 'D': 9, 'F': 10 };
|
||||
|
||||
async function fetchTop(limit = 8) {
|
||||
const res = await axios.get(`${API_BASE}/api/props/top-graded?limit=${limit}`, { timeout: 10_000 }).catch(() => null);
|
||||
return Array.isArray(res?.data?.props) ? res.data.props : [];
|
||||
}
|
||||
|
||||
function pickGOTD(props) {
|
||||
if (!props.length) return null;
|
||||
const sorted = [...props].sort((a, b) => {
|
||||
const ra = GRADE_RANK[a.grade] ?? 99;
|
||||
const rb = GRADE_RANK[b.grade] ?? 99;
|
||||
if (ra !== rb) return ra - rb;
|
||||
return (b.confidence ?? 0) - (a.confidence ?? 0);
|
||||
});
|
||||
return sorted[0];
|
||||
}
|
||||
|
||||
async function generate({ format = 'square' } = {}) {
|
||||
const props = await fetchTop(8);
|
||||
const winner = pickGOTD(props);
|
||||
if (!winner) {
|
||||
return { data: null, imageBuffer: null, imageUrl: null, note: 'no grades available' };
|
||||
}
|
||||
|
||||
const payload = {
|
||||
player: winner.player_name || winner.player,
|
||||
sport: winner.sport,
|
||||
stat: winner.stat_type || winner.stat,
|
||||
line: winner.line,
|
||||
direction: winner.direction,
|
||||
grade: winner.grade,
|
||||
projection: winner.projection,
|
||||
summary: winner.one_line_reason || winner.reasoning?.summary || null,
|
||||
};
|
||||
|
||||
let card = null;
|
||||
try {
|
||||
card = await buildCard({ type: 'gotd', format, payload });
|
||||
} catch (err) {
|
||||
console.error('[gradeOfTheDay] card build failed:', err.message);
|
||||
}
|
||||
|
||||
return {
|
||||
data: payload,
|
||||
imageBuffer: card?.buffer || null,
|
||||
imageContentType: card?.contentType || null,
|
||||
imageUrl: publicCardUrl({ type: 'gotd', format, payload }),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { generate, pickGOTD };
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Results generator — runs daily ~9 AM ET via n8n.
|
||||
*
|
||||
* 1. Query yesterday's resolved grades from the Ledger.
|
||||
* 2. Compute hits / misses / accuracy / CLV summary.
|
||||
* 3. Build a recap share card.
|
||||
* 4. Return { data, imageBuffer, imageUrl } for downstream channels.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { buildCard, publicCardUrl } = require('./_shareCardClient');
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost:4000';
|
||||
|
||||
function yesterdayISO() {
|
||||
const d = new Date(Date.now() - 24 * 60 * 60_000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dateLabel(iso) {
|
||||
return new Date(`${iso}T12:00:00Z`).toLocaleDateString('en-US', {
|
||||
month: 'long', day: 'numeric', year: 'numeric', timeZone: 'America/New_York',
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchLedgerForDate(date) {
|
||||
const res = await axios.get(`${API_BASE}/api/ledger?date=${date}`, { timeout: 10_000 }).catch(() => null);
|
||||
const rows = Array.isArray(res?.data?.entries) ? res.data.entries : [];
|
||||
return rows;
|
||||
}
|
||||
|
||||
function summarize(entries) {
|
||||
const hits = entries.filter((e) => e.result === 'hit').length;
|
||||
const misses = entries.filter((e) => e.result === 'miss').length;
|
||||
const total = hits + misses;
|
||||
return {
|
||||
total,
|
||||
hits,
|
||||
misses,
|
||||
accuracy: total ? Math.round((hits / total) * 100) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function generate({ format = 'square', date = null } = {}) {
|
||||
const iso = date || yesterdayISO();
|
||||
const raw = await fetchLedgerForDate(iso);
|
||||
const stats = summarize(raw);
|
||||
|
||||
const payload = {
|
||||
date: dateLabel(iso),
|
||||
accuracy: stats.accuracy,
|
||||
entries: raw.slice(0, 6).map((e) => ({
|
||||
player: e.player_name || e.player,
|
||||
stat: e.stat_type || e.stat,
|
||||
direction: e.direction,
|
||||
line: e.line,
|
||||
grade: e.grade,
|
||||
result: e.result,
|
||||
})),
|
||||
};
|
||||
|
||||
let card = null;
|
||||
try {
|
||||
card = await buildCard({ type: 'recap', format, payload });
|
||||
} catch (err) {
|
||||
console.error('[resultsGenerator] card build failed:', err.message);
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...payload, ...stats },
|
||||
imageBuffer: card?.buffer || null,
|
||||
imageContentType: card?.contentType || null,
|
||||
imageUrl: publicCardUrl({ type: 'recap', format, payload }),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
@@ -0,0 +1,22 @@
|
||||
function phiCoefficient(p11, p10, p01, p00) {
|
||||
const n = p11 + p10 + p01 + p00;
|
||||
if (n === 0) return 0;
|
||||
const p1plus = p11 + p10;
|
||||
const pplus1 = p11 + p01;
|
||||
const p0plus = p01 + p00;
|
||||
const pplus0 = p10 + p00;
|
||||
const denom = Math.sqrt(p1plus * pplus1 * p0plus * pplus0);
|
||||
if (denom === 0) return 0;
|
||||
return (p11 * p00 - p10 * p01) / denom;
|
||||
}
|
||||
|
||||
function hasMinimumObservations(sampleSize, minimum = 100) {
|
||||
return sampleSize >= minimum;
|
||||
}
|
||||
|
||||
function calculateJuiceAdjustedEV(modelProb, stake = 110) {
|
||||
const payout = 100;
|
||||
return (modelProb * payout) - ((1 - modelProb) * stake);
|
||||
}
|
||||
|
||||
module.exports = { phiCoefficient, hasMinimumObservations, calculateJuiceAdjustedEV };
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Discord webhook push.
|
||||
*
|
||||
* One outgoing webhook per channel. No bot library, no gateway connection;
|
||||
* just a POST per message. Embeds render the share card via the `image`
|
||||
* field — Discord fetches the URL server-side, so the URL must be publicly
|
||||
* reachable (n8n can hand the share-card buffer to a CDN if needed).
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const FormData = require('form-data');
|
||||
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const VYNDR_GREEN = 0x00D4A0;
|
||||
|
||||
const WEBHOOKS = Object.freeze({
|
||||
daily: process.env.DISCORD_WEBHOOK_DAILY || '',
|
||||
results: process.env.DISCORD_WEBHOOK_RESULTS || '',
|
||||
alerts: process.env.DISCORD_WEBHOOK_ALERTS || '',
|
||||
rare: process.env.DISCORD_WEBHOOK_RARE || '',
|
||||
});
|
||||
|
||||
function webhookFor(channel) {
|
||||
const url = WEBHOOKS[channel];
|
||||
if (!url) return null;
|
||||
// Don't accept arbitrary user input — only the small set above.
|
||||
if (!url.startsWith('https://discord.com/api/webhooks/') &&
|
||||
!url.startsWith('https://discordapp.com/api/webhooks/')) {
|
||||
return null;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function postToDiscord(channel, { text, imageUrl, imageBuffer, color } = {}) {
|
||||
const url = webhookFor(channel);
|
||||
if (!url) return { ok: false, error: `no webhook for ${channel}` };
|
||||
|
||||
const embed = {
|
||||
description: text || '',
|
||||
color: color || VYNDR_GREEN,
|
||||
image: imageUrl ? { url: imageUrl } : undefined,
|
||||
footer: { text: 'VYNDR · vyndr.app' },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const payload = { username: 'VYNDR', embeds: [embed] };
|
||||
|
||||
try {
|
||||
if (imageBuffer && Buffer.isBuffer(imageBuffer)) {
|
||||
// Multipart with attached PNG. Discord renders attachments inline.
|
||||
const form = new FormData();
|
||||
form.append('payload_json', JSON.stringify({
|
||||
username: 'VYNDR',
|
||||
embeds: [{
|
||||
description: text || '',
|
||||
color: color || VYNDR_GREEN,
|
||||
image: { url: 'attachment://vyndr.png' },
|
||||
footer: { text: 'VYNDR · vyndr.app' },
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
}));
|
||||
form.append('file1', imageBuffer, { filename: 'vyndr.png', contentType: 'image/png' });
|
||||
await axios.post(url, form, {
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
headers: form.getHeaders(),
|
||||
maxContentLength: 8 * 1024 * 1024,
|
||||
maxBodyLength: 8 * 1024 * 1024,
|
||||
});
|
||||
return { ok: true, channel, mode: 'attachment' };
|
||||
}
|
||||
await axios.post(url, payload, { timeout: HTTP_TIMEOUT_MS });
|
||||
return { ok: true, channel, mode: 'embed' };
|
||||
} catch (err) {
|
||||
const detail = err?.response?.data || err?.message || 'unknown';
|
||||
console.error(`[discord:${channel}] push failed:`, detail);
|
||||
return { ok: false, error: typeof detail === 'string' ? detail : JSON.stringify(detail) };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { postToDiscord, webhookFor };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user