Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+104
View File
@@ -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;
+57
View File
@@ -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
}
]
}
+249
View File
@@ -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,
};
+30
View File
@@ -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 };
+44
View File
@@ -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 };
+5
View File
@@ -0,0 +1,5 @@
function missionHeader(req, res, next) {
res.setHeader('X-VYNDR-Mission', 'bet-on-intelligence');
next();
}
module.exports = { missionHeader };
+2 -2
View File
@@ -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' });
}
});
+1 -1
View File
@@ -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
View File
@@ -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' });
}
});
+168
View File
@@ -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;
+436
View File
@@ -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 };
+1 -1
View File
@@ -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
View File
@@ -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({
+59
View File
@@ -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;
+81
View File
@@ -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;
+87
View File
@@ -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
View File
@@ -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' });
}
});
+218
View File
@@ -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;
+111
View File
@@ -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;
+5 -5
View File
@@ -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' });
}
});
+32
View File
@@ -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;
+108
View File
@@ -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
View File
@@ -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}`);
});
+129
View File
@@ -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 };
+13
View File
@@ -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 };
+13
View File
@@ -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 };
+14
View File
@@ -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 };
+100
View File
@@ -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 };
+14
View File
@@ -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 };
+42
View File
@@ -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 };
+96
View File
@@ -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 };
+16
View File
@@ -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 };
+97
View File
@@ -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 },
};
+157
View File
@@ -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 },
};
+157
View File
@@ -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 },
};
+130
View File
@@ -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 },
};
+116
View File
@@ -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 },
};
+229
View File
@@ -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 },
};
+110
View File
@@ -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,
};
+149
View File
@@ -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,
};
+113
View File
@@ -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 };
+47
View File
@@ -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 };
+84
View File
@@ -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 };
+65
View File
@@ -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 };
+77
View File
@@ -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 };
+22
View File
@@ -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 };
+79
View File
@@ -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 };
+72
View File
@@ -0,0 +1,72 @@
/**
* Telegram channel push.
*
* Webhook-style: one-direction POST to Telegram's Bot API. No polling,
* no command handling. The bot token + channel ID come from env.
*
* sendPhoto accepts either a Buffer (multipart upload) or a URL (the
* Telegram fetcher will pull it). We prefer the URL path when the share
* card lives behind a stable public endpoint.
*/
const axios = require('axios');
const FormData = require('form-data');
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
const CHANNEL_ID = process.env.TELEGRAM_CHANNEL_ID;
const HTTP_TIMEOUT_MS = 12_000;
function configured() {
return !!(BOT_TOKEN && CHANNEL_ID);
}
function endpoint(method) {
return `https://api.telegram.org/bot${BOT_TOKEN}/${method}`;
}
async function postToTelegram({ text, imageBuffer, imageUrl, parseMode = 'HTML' } = {}) {
if (!configured()) {
return { ok: false, error: 'TELEGRAM_BOT_TOKEN or TELEGRAM_CHANNEL_ID not set' };
}
try {
if (imageBuffer && Buffer.isBuffer(imageBuffer)) {
const form = new FormData();
form.append('chat_id', CHANNEL_ID);
if (text) form.append('caption', text);
form.append('parse_mode', parseMode);
form.append('photo', imageBuffer, { filename: 'vyndr.png', contentType: 'image/png' });
await axios.post(endpoint('sendPhoto'), form, {
timeout: HTTP_TIMEOUT_MS,
headers: form.getHeaders(),
maxContentLength: 8 * 1024 * 1024,
maxBodyLength: 8 * 1024 * 1024,
});
return { ok: true, mode: 'photo-buffer' };
}
if (imageUrl) {
await axios.post(endpoint('sendPhoto'), {
chat_id: CHANNEL_ID,
photo: imageUrl,
caption: text || '',
parse_mode: parseMode,
}, { timeout: HTTP_TIMEOUT_MS });
return { ok: true, mode: 'photo-url' };
}
if (text) {
await axios.post(endpoint('sendMessage'), {
chat_id: CHANNEL_ID,
text,
parse_mode: parseMode,
disable_web_page_preview: false,
}, { timeout: HTTP_TIMEOUT_MS });
return { ok: true, mode: 'text' };
}
return { ok: false, error: 'nothing to send' };
} catch (err) {
const detail = err?.response?.data || err?.message || 'unknown';
console.error('[telegram] push failed:', detail);
return { ok: false, error: typeof detail === 'string' ? detail : JSON.stringify(detail) };
}
}
module.exports = { postToTelegram, configured };
+126
View File
@@ -0,0 +1,126 @@
/**
* Web Push delivery.
*
* One-direction POST to the user's browser push service (FCM, Mozilla, Apple).
* Subscriptions are stored in push_subscriptions (migration 015) and the
* service worker in web/src/sw.ts handles the `push` event.
*
* A 410 (Gone) or 404 response means the subscription is dead — we delete
* the row so we stop trying. Anything else is logged and treated as transient.
*/
const webpush = require('web-push');
const { getSupabaseServiceClient } = require('../../utils/supabase');
const VAPID_PUBLIC = process.env.VAPID_PUBLIC_KEY;
const VAPID_PRIVATE = process.env.VAPID_PRIVATE_KEY;
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:contact@vyndr.app';
let _initialized = false;
function ensureInit() {
if (_initialized) return true;
if (!VAPID_PUBLIC || !VAPID_PRIVATE) return false;
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC, VAPID_PRIVATE);
_initialized = true;
return true;
}
function configured() {
return !!(VAPID_PUBLIC && VAPID_PRIVATE);
}
function rowToSubscription(row) {
return {
endpoint: row.endpoint,
keys: { p256dh: row.keys_p256dh, auth: row.keys_auth },
};
}
async function deleteSubscription(supabase, subscriptionId) {
await supabase.from('push_subscriptions').delete().eq('id', subscriptionId);
}
async function sendOne(supabase, row, payload) {
try {
await webpush.sendNotification(rowToSubscription(row), JSON.stringify(payload));
return { ok: true, id: row.id };
} catch (err) {
const status = err?.statusCode;
if (status === 404 || status === 410) {
await deleteSubscription(supabase, row.id);
return { ok: false, id: row.id, pruned: true };
}
console.warn('[webPush] send failed:', { id: row.id, status, message: err?.message });
return { ok: false, id: row.id, error: err?.message };
}
}
async function sendPushToUser(userId, notification) {
if (!configured() || !ensureInit()) {
return { ok: false, error: 'VAPID keys not configured' };
}
const supabase = getSupabaseServiceClient();
const { data: rows, error } = await supabase
.from('push_subscriptions')
.select('id, endpoint, keys_p256dh, keys_auth')
.eq('user_id', userId);
if (error) return { ok: false, error: error.message };
if (!rows || rows.length === 0) return { ok: true, sent: 0 };
const results = await Promise.allSettled(rows.map((row) => sendOne(supabase, row, notification)));
const summary = results.reduce(
(acc, r) => {
if (r.status === 'fulfilled' && r.value.ok) acc.sent += 1;
else if (r.status === 'fulfilled' && r.value.pruned) acc.pruned += 1;
else acc.failed += 1;
return acc;
},
{ sent: 0, pruned: 0, failed: 0 }
);
return { ok: true, ...summary };
}
async function sendPushToSport(sport, notification, opts = {}) {
if (!configured() || !ensureInit()) {
return { ok: false, error: 'VAPID keys not configured' };
}
const { kind } = opts;
const supabase = getSupabaseServiceClient();
let query = supabase
.from('push_subscriptions')
.select('id, endpoint, keys_p256dh, keys_auth')
.contains('sport_preferences', [sport]);
if (kind === 'resolution') query = query.eq('notify_on_resolution', true);
if (kind === 'cascade') query = query.eq('notify_on_cascade', true);
if (kind === 'cheatsheet') query = query.eq('notify_on_cheatsheet', true);
const { data: rows, error } = await query;
if (error) return { ok: false, error: error.message };
if (!rows || rows.length === 0) return { ok: true, sent: 0 };
const results = await Promise.allSettled(rows.map((row) => sendOne(supabase, row, notification)));
const summary = results.reduce(
(acc, r) => {
if (r.status === 'fulfilled' && r.value.ok) acc.sent += 1;
else if (r.status === 'fulfilled' && r.value.pruned) acc.pruned += 1;
else acc.failed += 1;
return acc;
},
{ sent: 0, pruned: 0, failed: 0 }
);
return { ok: true, ...summary };
}
async function cleanupExpired() {
// Called from a scheduled task. Walks every subscription and pings the
// push service with an empty payload — anything that 410s gets pruned.
// For now a no-op stub; cleanup happens lazily on send failures above.
return { ok: true, note: 'lazy cleanup runs on send failures' };
}
module.exports = {
configured,
sendPushToUser,
sendPushToSport,
cleanupExpired,
};
+193
View File
@@ -0,0 +1,193 @@
/**
* Backend email templates.
*
* These produce the `{subject, html, text}` payload that the broadcast
* layer (Listmonk, Ghost, etc.) ships to subscribers. We do not call SMTP
* directly — the broadcast platform handles deliverability, unsubscribe
* tokens, list management.
*
* Subject lines are content-driven per spec: NOT "VYNDR Daily Cheatsheet
* — May 28"; instead something like "Jokic A+, 3 kills on the Lakers
* game, 8 games tonight".
*
* SECURITY: every interpolated value passes through `esc()` so no
* subscriber-controlled string can inject HTML.
*/
const SITE = process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app';
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function htmlShell(body) {
return `<!doctype html>
<html><body style="margin:0;padding:32px 16px;background:#06060B;color:#E8E8F0;font-family:'Instrument Sans',-apple-system,system-ui,sans-serif;line-height:1.6">
<div style="max-width:600px;margin:0 auto;background:#0E0E16;border:1px solid #1E1E2E;border-radius:16px;padding:32px">
<h1 style="font-family:'IBM Plex Mono','JetBrains Mono',monospace;font-size:22px;font-weight:800;letter-spacing:0.10em;margin:0 0 24px;color:#E8E8F0">
VYND<span style="color:#00D4A0;text-shadow:0 0 12px rgba(0,212,160,0.6)">R</span>
</h1>
${body}
<hr style="border:none;border-top:1px solid #1E1E2E;margin:32px 0 16px" />
<p style="font-size:11px;color:#4A4A5E;margin:0;font-family:'IBM Plex Mono',monospace">
VYNDR is an analytics tool, not a sportsbook. Gamble responsibly. 1-800-522-4700.<br/>
Reply STOP to unsubscribe.
</p>
</div>
</body></html>`;
}
// ── Cheatsheet — daily 4:30 PM ET ────────────────────────────────────────
function buildCheatsheetEmail(cheatsheetData = {}) {
const grades = Array.isArray(cheatsheetData.grades) ? cheatsheetData.grades : [];
const top = grades[0];
const gameCount = cheatsheetData.gameCount ?? 0;
// Content-driven subject:
// "Jokic A+, 3 kills on the Lakers game, 8 games tonight"
const parts = [];
if (top) parts.push(`${top.player || 'top'} ${top.grade || ''}`.trim());
if (cheatsheetData.killCount && cheatsheetData.killTeam) {
parts.push(`${cheatsheetData.killCount} kills on the ${cheatsheetData.killTeam} game`);
}
parts.push(`${gameCount} game${gameCount === 1 ? '' : 's'} tonight`);
const subject = parts.join(', ');
const rows = grades.slice(0, 6).map((g) => `
<tr>
<td style="padding:10px 0;color:#00D4A0;font-family:'IBM Plex Mono',monospace;font-weight:800;font-size:18px;width:60px">${esc(g.grade)}</td>
<td style="padding:10px 0;color:#E8E8F0;font-weight:600">${esc(g.player)}</td>
<td style="padding:10px 0;color:#7A7A8E;font-family:'IBM Plex Mono',monospace;text-align:right">${esc(`${g.stat || ''} ${String(g.direction || '').toUpperCase()} ${g.line ?? ''}`)}</td>
</tr>`).join('');
const body = `
<p style="font-size:16px"><strong>Tonight's cheatsheet.</strong></p>
<p style="color:#7A7A8E;font-size:14px">${esc(cheatsheetData.date || '')} · ${esc(String(gameCount))} games</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0">${rows}</table>
${cheatsheetData.imageUrl ? `<p style="margin:16px 0"><img src="${esc(cheatsheetData.imageUrl)}" alt="VYNDR cheatsheet" style="max-width:100%;border-radius:12px;display:block"/></p>` : ''}
<p style="margin-top:24px">
<a href="${esc(SITE)}/dashboard"
style="display:inline-block;padding:12px 24px;background:#1A5A42;color:#E8FFF4;text-decoration:none;border-radius:12px;font-weight:600">
See the full slate →
</a>
</p>`;
const text =
`Tonight's cheatsheet.
${cheatsheetData.date || ''} · ${gameCount} games
${grades.slice(0, 6).map((g) => `${g.grade}\t${g.player}\t${g.stat || ''} ${String(g.direction || '').toUpperCase()} ${g.line ?? ''}`).join('\n')}
See the full slate: ${SITE}/dashboard
VYNDR · vyndr.app · @getvyndr
Reply STOP to unsubscribe.`;
return { subject, html: htmlShell(body), text };
}
// ── Results — daily 9 AM ET ──────────────────────────────────────────────
function buildResultsEmail(resultsData = {}) {
const accuracy = resultsData.accuracy ?? 0;
const hits = resultsData.hits ?? 0;
const total = resultsData.total ?? 0;
const entries = Array.isArray(resultsData.entries) ? resultsData.entries : [];
const subject = `Last night: ${accuracy}% — ${hits} of ${total} grades hit`;
const rows = entries.slice(0, 8).map((e) => {
const hit = e.result === 'hit';
const tint = hit ? 'rgba(0,212,160,0.10)' : 'rgba(255,82,82,0.08)';
const mark = hit ? '✓' : '✗';
const markColor = hit ? '#00D4A0' : '#FF5252';
return `
<tr style="background:${tint}">
<td style="padding:10px 12px;color:${markColor};font-family:'IBM Plex Mono',monospace;font-weight:800;font-size:18px;width:32px">${mark}</td>
<td style="padding:10px 12px;color:#E8E8F0;font-weight:600">${esc(e.player)}</td>
<td style="padding:10px 12px;color:#7A7A8E;font-family:'IBM Plex Mono',monospace;font-size:13px">${esc(`${e.stat || ''} ${String(e.direction || '').toUpperCase()} ${e.line ?? ''}`)}</td>
<td style="padding:10px 12px;color:#00D4A0;font-family:'IBM Plex Mono',monospace;font-weight:800;text-align:right">${esc(e.grade)}</td>
<td style="padding:10px 12px;color:${markColor};font-family:'IBM Plex Mono',monospace;font-weight:700;text-align:right">${hit ? 'HIT' : 'MISS'}</td>
</tr>`;
}).join('');
const body = `
<p style="font-size:16px"><strong>Last night's results.</strong></p>
<p style="color:#7A7A8E;font-size:14px">${esc(resultsData.date || '')}</p>
<p style="font-family:'IBM Plex Mono',monospace;font-size:32px;font-weight:800;color:#00D4A0;text-shadow:0 0 12px rgba(0,212,160,0.6);margin:8px 0">
${accuracy}% <span style="font-size:14px;color:#7A7A8E">(${hits}/${total})</span>
</p>
<table style="width:100%;border-collapse:separate;border-spacing:0 4px;margin:16px 0">${rows}</table>
${resultsData.imageUrl ? `<p style="margin:16px 0"><img src="${esc(resultsData.imageUrl)}" alt="VYNDR recap" style="max-width:100%;border-radius:12px;display:block"/></p>` : ''}
<p style="margin-top:24px">
<a href="${esc(SITE)}/ledger"
style="display:inline-block;padding:12px 24px;background:#1A5A42;color:#E8FFF4;text-decoration:none;border-radius:12px;font-weight:600">
See the full Ledger →
</a>
</p>`;
const text =
`Last night: ${accuracy}% — ${hits} of ${total} grades hit
${resultsData.date || ''}
${entries.slice(0, 8).map((e) => `${e.result === 'hit' ? 'HIT' : 'MISS'}\t${e.player}\t${e.stat || ''} ${String(e.direction || '').toUpperCase()} ${e.line ?? ''}\t${e.grade}`).join('\n')}
Full Ledger: ${SITE}/ledger
VYNDR · vyndr.app · @getvyndr
Reply STOP to unsubscribe.`;
return { subject, html: htmlShell(body), text };
}
// ── Cascade alert — real-time ────────────────────────────────────────────
function buildCascadeAlertEmail(cascadeData = {}) {
const trigger = cascadeData.trigger_detail || cascadeData.detail || {};
const headline = trigger.player && trigger.status
? `${trigger.player} ${trigger.status}`
: (trigger.summary || cascadeData.trigger_type || 'event');
const count = Array.isArray(cascadeData.affected_props) ? cascadeData.affected_props.length : (cascadeData.affected_count ?? 0);
const subject = `🚨 ${headline}${count} prop${count === 1 ? '' : 's'} affected`;
const rows = (cascadeData.affected_props || []).slice(0, 6).map((p) => `
<tr>
<td style="padding:10px 0;color:#E8E8F0;font-weight:600">${esc(p.player)}</td>
<td style="padding:10px 0;color:#7A7A8E;font-family:'IBM Plex Mono',monospace;font-size:13px">${esc(`${p.stat || ''} ${String(p.direction || '').toUpperCase()} ${p.line ?? ''}`)}</td>
<td style="padding:10px 0;color:#7A7A8E;font-family:'IBM Plex Mono',monospace;font-weight:700;text-align:right">${esc(p.old_grade || '—')} → <span style="color:#00D4A0">${esc(p.new_grade || '—')}</span></td>
</tr>`).join('');
const body = `
<p style="font-family:'IBM Plex Mono',monospace;font-size:13px;letter-spacing:4px;color:#FFB347;text-transform:uppercase;margin-bottom:8px">CASCADE · ${esc((cascadeData.trigger_type || 'event').toUpperCase())}</p>
<p style="font-size:18px;font-weight:700;margin:0">${esc(headline)}${count} prop${count === 1 ? '' : 's'} affected.</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0">${rows}</table>
${cascadeData.imageUrl ? `<p style="margin:16px 0"><img src="${esc(cascadeData.imageUrl)}" alt="VYNDR cascade" style="max-width:100%;border-radius:12px;display:block"/></p>` : ''}
<p style="margin-top:20px">
<a href="${esc(SITE)}/dashboard"
style="display:inline-block;padding:12px 24px;background:#1A5A42;color:#E8FFF4;text-decoration:none;border-radius:12px;font-weight:600">
See the updated grades →
</a>
</p>`;
const text =
`${headline}${count} prop${count === 1 ? '' : 's'} affected.
${(cascadeData.affected_props || []).slice(0, 6).map((p) => `${p.player}\t${p.stat || ''} ${String(p.direction || '').toUpperCase()} ${p.line ?? ''}\t${p.old_grade || '—'}${p.new_grade || '—'}`).join('\n')}
Updated grades: ${SITE}/dashboard
VYNDR · vyndr.app · @getvyndr
Reply STOP to unsubscribe.`;
return { subject, html: htmlShell(body), text };
}
module.exports = { buildCheatsheetEmail, buildResultsEmail, buildCascadeAlertEmail };
+96
View File
@@ -0,0 +1,96 @@
const axios = require('axios');
const EVOLUTION_SERVICE_URL = process.env.EVOLUTION_SERVICE_URL || 'http://localhost:5001';
const EVOLUTION_TIMEOUT = 5000;
const TRACKED_SIGNALS = [
'usage_rate',
'assist_rate',
'three_pt_attempt_rate',
'shot_location',
'aggression_score',
'minutes_trajectory',
];
/**
* Detect changepoints in a player metric time series via Python PELT microservice.
* @param {string} playerId
* @param {string} metric - Metric name
* @param {Array<number>} values - Time series values
* @param {Array<string>} timestamps - ISO timestamps
* @returns {object} Changepoint result or graceful degradation
*/
async function detectChangepoints(playerId, metric, values, timestamps) {
try {
const response = await axios.post(
`${EVOLUTION_SERVICE_URL}/detect`,
{ player_id: playerId, metric, values, timestamps },
{ timeout: EVOLUTION_TIMEOUT }
);
return response.data;
} catch (error) {
const reason = error.code === 'ECONNABORTED'
? 'timeout'
: error.response
? `HTTP ${error.response.status}`
: error.message;
return {
evolution_detected: false,
error: reason,
playerId,
metric,
};
}
}
/**
* Check if multiple signals are inflecting simultaneously.
* If 2+ signals inflecting above 70% confidence, evolution is detected.
* @param {object} playerMetrics - { signal_name: { values, timestamps, confidence } }
* @returns {object} { evolution_detected, confidence, signals }
*/
async function checkMultiSignalEvolution(playerMetrics) {
const results = [];
for (const signal of TRACKED_SIGNALS) {
if (playerMetrics[signal]) {
const { values, timestamps } = playerMetrics[signal];
const result = await detectChangepoints(
playerMetrics.playerId,
signal,
values,
timestamps
);
if (result && !result.error) {
results.push({ signal, ...result });
}
}
}
const inflecting = results.filter(r => r.confidence >= 0.70);
if (inflecting.length >= 2) {
const avgConfidence = inflecting.reduce((s, r) => s + r.confidence, 0) / inflecting.length;
return {
evolution_detected: true,
confidence: Math.round(avgConfidence * 100) / 100,
signals: inflecting.map(r => r.signal),
details: inflecting,
};
}
return {
evolution_detected: false,
confidence: 0,
signals: [],
checked: TRACKED_SIGNALS.filter(s => playerMetrics[s]),
};
}
module.exports = {
EVOLUTION_SERVICE_URL,
EVOLUTION_TIMEOUT,
TRACKED_SIGNALS,
detectChangepoints,
checkMultiSignalEvolution,
};
@@ -0,0 +1,154 @@
/**
* Per-grade-tier accuracy tracking.
*
* Every resolution increments counters for (sport, grade, period).
* The "all_time" period is the canonical record; "last_30d" and
* "last_7d" are derived views recomputed by a periodic refresh job
* (n8n). We update all three counters on every resolve so callers can
* read instant values without rolling a window themselves.
*
* BASELINE LOCK:
* After a (sport, grade, 'all_time') accumulates 100 decisive
* resolutions (hits + misses, not push/void), the hit rate at that
* moment is locked as `baseline_hit_rate` and `baseline_locked`
* flips to true. Future accuracy compares to the baseline to detect
* drift.
*
* EXPECTED HIT RATES (from spec):
* A+ ≥ 65%, A ≥ 60%, A- ≥ 58%, B+ ≥ 55%, B ≥ 53%, B- ≥ 51%
* C+ ≈ 50%, C ≈ 48%, C- ≈ 45%, D ≈ 40%, F ≈ 35%
*/
const { getSupabaseServiceClient } = require('../../utils/supabase');
const BASELINE_LOCK_AT = 100;
const PERIODS = ['all_time', 'last_30d', 'last_7d'];
const EXPECTED_HIT_RATES = Object.freeze({
'A+': 0.65, 'A': 0.60, 'A-': 0.58,
'B+': 0.55, 'B': 0.53, 'B-': 0.51,
'C+': 0.50, 'C': 0.48, 'C-': 0.45,
'D': 0.40, 'F': 0.35,
});
function computeHitRate(hit, miss) {
const denom = hit + miss;
return denom > 0 ? hit / denom : null;
}
async function fetchRow(supabase, sport, grade, period) {
const { data, error } = await supabase
.from('accuracy_tracking')
.select('*')
.eq('sport', sport)
.eq('grade', grade)
.eq('period', period)
.maybeSingle();
if (error) {
console.warn('[accuracy] fetch failed:', error.message);
return null;
}
return data;
}
async function upsertRow(supabase, row) {
const { error } = await supabase
.from('accuracy_tracking')
.upsert(row, { onConflict: 'sport,grade,period' });
if (error) console.warn('[accuracy] upsert failed:', error.message);
}
async function recordResolution(sport, grade, result) {
if (!sport || !grade || !result) return;
const supabase = getSupabaseServiceClient();
for (const period of PERIODS) {
const existing = await fetchRow(supabase, sport, grade, period) || {
sport, grade, period,
total_graded: 0, total_hit: 0, total_miss: 0, total_push: 0, total_void: 0,
hit_rate: null, baseline_hit_rate: null, baseline_locked: false,
};
existing.total_graded += 1;
if (result === 'hit') existing.total_hit += 1;
else if (result === 'miss') existing.total_miss += 1;
else if (result === 'push') existing.total_push += 1;
else if (result === 'void') existing.total_void += 1;
existing.hit_rate = computeHitRate(existing.total_hit, existing.total_miss);
if (
period === 'all_time'
&& !existing.baseline_locked
&& (existing.total_hit + existing.total_miss) >= BASELINE_LOCK_AT
) {
existing.baseline_hit_rate = existing.hit_rate;
existing.baseline_locked = true;
}
existing.last_updated = new Date().toISOString();
await upsertRow(supabase, existing);
}
}
async function getAccuracy(sport, grade, period = 'all_time') {
const supabase = getSupabaseServiceClient();
const row = await fetchRow(supabase, sport, grade, period);
if (!row) {
return {
sport, grade, period,
hit_rate: null,
baseline: null,
expected: EXPECTED_HIT_RATES[grade] ?? null,
total: 0,
delta: null,
locked: false,
};
}
const total = row.total_hit + row.total_miss;
const delta = row.baseline_hit_rate != null && row.hit_rate != null
? row.hit_rate - row.baseline_hit_rate
: null;
return {
sport, grade, period,
hit_rate: row.hit_rate,
baseline: row.baseline_hit_rate,
expected: EXPECTED_HIT_RATES[grade] ?? null,
total,
delta,
locked: !!row.baseline_locked,
};
}
async function getAllAccuracy(sport, period = 'all_time') {
const grades = Object.keys(EXPECTED_HIT_RATES);
const out = [];
for (const grade of grades) out.push(await getAccuracy(sport, grade, period));
return out;
}
async function isBaselineLocked(sport, grade) {
const supabase = getSupabaseServiceClient();
const row = await fetchRow(supabase, sport, grade, 'all_time');
return !!row?.baseline_locked;
}
async function getAccuracyDashboard() {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('accuracy_tracking')
.select('*')
.eq('period', 'all_time');
if (error) {
console.warn('[accuracy] dashboard query failed:', error.message);
return [];
}
return data || [];
}
module.exports = {
recordResolution,
getAccuracy,
getAllAccuracy,
isBaselineLocked,
getAccuracyDashboard,
EXPECTED_HIT_RATES,
BASELINE_LOCK_AT,
__internals: { computeHitRate, PERIODS },
};
+151
View File
@@ -0,0 +1,151 @@
/**
* Closing Line Value (CLV) tracking.
*
* CLV measures how much edge we found vs the market close. Beating the
* close consistently is the canonical signal of real edge, regardless
* of any individual prop's outcome. This is how we prove (to ourselves
* and to users) that VYNDR's grades are doing something real.
*
* Computation:
* - For OVER: CLV = closing_line - graded_line
* We graded a line at 25.5, close was 27.5 → we saw the over was
* too cheap before the market did → +2.0 CLV.
* - For UNDER: CLV = graded_line - closing_line
* We graded under 25.5, close was 23.5 → +2.0 CLV.
*
* Closing lines come from oddspapi via the resolution poller, stored in
* closing_lines (migration 016). The match key is
* (game_id, player_espn_id OR player_name, stat_type)
* so a graded prop without a captured close returns null — not zero.
*/
const { getSupabaseServiceClient } = require('../../utils/supabase');
function rawCLV(direction, gradedLine, closingLine) {
// Guard against null/undefined first — Number(null) === 0 is finite,
// which would silently produce a 0-based CLV instead of "unknown."
if (gradedLine == null || closingLine == null) return null;
const g = Number(gradedLine);
const c = Number(closingLine);
if (!Number.isFinite(g) || !Number.isFinite(c)) return null;
return direction === 'over' ? c - g : g - c;
}
async function fetchGrade(gradeId) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('grade_history')
.select('id, game_id, sport, player_id, player_name, stat_type, line, direction, clv')
.eq('id', gradeId)
.maybeSingle();
if (error) {
console.warn('[clv] grade lookup failed:', error.message);
return null;
}
return data;
}
async function fetchClosingLine(grade) {
const supabase = getSupabaseServiceClient();
let query = supabase
.from('closing_lines')
.select('id, pinnacle_line')
.eq('game_id', grade.game_id)
.eq('stat_type', grade.stat_type);
// Prefer ID match (canonical), fall back to name match.
query = grade.player_id
? query.eq('player_espn_id', grade.player_id)
: query.eq('player_name', grade.player_name);
const { data, error } = await query.maybeSingle();
if (error) {
console.warn('[clv] closing line lookup failed:', error.message);
return null;
}
return data;
}
async function persistCLV(gradeId, clv, closingLineId) {
const supabase = getSupabaseServiceClient();
const { error } = await supabase
.from('grade_history')
.update({ clv, closing_line_id: closingLineId || null })
.eq('id', gradeId);
if (error) console.warn('[clv] persist failed:', error.message);
}
async function computeCLV(gradeId) {
const grade = await fetchGrade(gradeId);
if (!grade) return null;
const closing = await fetchClosingLine(grade);
if (!closing) {
return {
gradeId,
clv: null,
graded_line: Number(grade.line),
closing_line: null,
direction: grade.direction,
sport: grade.sport,
reason: 'no_closing_line',
};
}
const clv = rawCLV(grade.direction, grade.line, closing.pinnacle_line);
if (clv != null) await persistCLV(gradeId, clv, closing.id);
return {
gradeId,
clv,
graded_line: Number(grade.line),
closing_line: Number(closing.pinnacle_line),
direction: grade.direction,
sport: grade.sport,
};
}
async function batchComputeCLV(gradeIds) {
const out = [];
for (const id of gradeIds) {
try { out.push(await computeCLV(id)); }
catch (err) {
console.warn('[clv] batch entry failed:', id, err.message);
out.push({ gradeId: id, clv: null, error: err.message });
}
}
return out;
}
async function getCLVSummary(sport, period = 'all_time') {
const supabase = getSupabaseServiceClient();
let query = supabase
.from('grade_history')
.select('clv')
.eq('sport', sport)
.not('clv', 'is', null);
if (period === 'last_30d') {
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
query = query.gte('graded_at', since);
} else if (period === 'last_7d') {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
query = query.gte('graded_at', since);
}
const { data, error } = await query;
if (error) {
console.warn('[clv] summary query failed:', error.message);
return { avg_clv: null, median_clv: null, positive_rate: null, total: 0 };
}
if (!data || data.length === 0) {
return { avg_clv: null, median_clv: null, positive_rate: null, total: 0 };
}
const values = data.map((r) => Number(r.clv)).filter((v) => Number.isFinite(v));
const avg = values.reduce((a, b) => a + b, 0) / values.length;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
const positive = values.filter((v) => v > 0).length;
return {
avg_clv: avg,
median_clv: median,
positive_rate: positive / values.length,
total: values.length,
};
}
module.exports = { computeCLV, batchComputeCLV, getCLVSummary, rawCLV };
+91
View File
@@ -0,0 +1,91 @@
/**
* Coach system + pace signal.
*
* Two signals exposed:
* coach_pace_delta: coach's career pace MINUS current team's pace,
* scaled by tenure (longer tenure = stronger
* adjustment).
* coach_player_interaction: magnitude of system shift when the primary
* player is OUT vs IN. Drives suppression for
* role players when the star sits.
*
* Profiles live in `coach_profiles` (migration 017). On first read for a
* team we check the table; if empty, fall back to the seed file at
* src/config/coaches.json so launch isn't blocked on a fully populated
* table.
*/
const path = require('path');
const fs = require('fs');
const { getSupabaseServiceClient } = require('../../utils/supabase');
let seedCache = null;
function loadSeed() {
if (seedCache !== null) return seedCache;
try {
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config', 'coaches.json'), 'utf8'));
seedCache = { coaches: raw.coaches || [] };
} catch {
seedCache = { coaches: [] };
}
return seedCache;
}
function tenureAdjustment(games) {
// Linear ramp to 1.0 over ~40 games — a coach inheriting a roster needs
// time before the system actually drifts toward their preference.
const g = Number(games) || 0;
return Math.min(1.0, Math.max(0, g / 40));
}
async function getCoachProfile(sport, teamAbbr) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('coach_profiles')
.select('coach_name, team, sport, career_avg_pace, current_team_pace, tenure_games, primary_player, system_style, without_primary_style, without_primary_pace_delta')
.eq('team', teamAbbr)
.eq('sport', sport)
.maybeSingle();
if (error) {
console.warn('[coachSignals] profile lookup failed:', error.message);
}
if (data) return data;
// Fall back to the seed file — same shape, different home.
const seed = loadSeed();
return seed.coaches.find((c) => c.team === teamAbbr && c.sport === sport) || null;
}
async function getCoachImpact(sport, teamAbbr, gameContext = {}) {
const profile = await getCoachProfile(sport, teamAbbr);
if (!profile) return null;
const career = Number(profile.career_avg_pace);
const team = Number(profile.current_team_pace);
const paceDelta = Number.isFinite(career) && Number.isFinite(team) ? career - team : null;
const tenureAdj = tenureAdjustment(profile.tenure_games);
const adjustedPaceDelta = paceDelta != null ? paceDelta * tenureAdj : null;
// Primary-player status comes from the caller — usually injuryParser told
// them whether the star is OUT/DOUBTFUL.
const primaryStatus = gameContext.primary_player_status ?? 'unknown';
const systemOverride = primaryStatus === 'out' || primaryStatus === 'doubtful'
? profile.without_primary_style
: null;
const withoutPrimaryShift = primaryStatus === 'out'
? Number(profile.without_primary_pace_delta) || 0
: 0;
return {
coach_name: profile.coach_name,
system_style: profile.system_style ?? null,
primary_player: profile.primary_player ?? null,
pace_delta: paceDelta,
tenure_adjustment: tenureAdj,
adjusted_pace_delta: adjustedPaceDelta,
primary_player_status: primaryStatus,
system_override: systemOverride,
without_primary_pace_shift: withoutPrimaryShift,
};
}
module.exports = { getCoachImpact, getCoachProfile, tenureAdjustment, loadSeed };
@@ -0,0 +1,66 @@
/**
* Consistency score — how predictable is this player for this stat?
*
* cv = stddev / mean
*
* Coefficient of variation collapses sample-size differences and lets us
* compare a 25-point scorer with low variance to a 12-point scorer with
* the same absolute variance. Lower cv = more reliable.
*
* The consistency score modifies Engine 2's confidence. An "elite"
* consistency player gets a tighter projection range; a "boom_bust"
* player gets a wider one.
*/
const gameLogService = require('./gameLogService');
function statFromGameLog(row, statType) {
if (!row) return null;
switch (statType) {
case 'pts_reb_ast':
return (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
case 'pts_reb':
return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
case 'pts_ast':
return (Number(row.points) || 0) + (Number(row.assists) || 0);
case 'reb_ast':
return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
case 'stl_blk':
return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
default: {
const v = Number(row[statType]);
return Number.isFinite(v) ? v : null;
}
}
}
function classify(cv) {
if (cv < 0.15) return { consistency: 'elite', score: 1.0 };
if (cv < 0.30) return { consistency: 'reliable', score: 0.7 };
if (cv < 0.50) return { consistency: 'volatile', score: 0.4 };
return { consistency: 'boom_bust', score: 0.1 };
}
function statsFor(values) {
const clean = values.filter((v) => Number.isFinite(v));
if (clean.length < 2) return null;
const mean = clean.reduce((a, b) => a + b, 0) / clean.length;
if (mean === 0) return null;
const variance = clean.reduce((s, v) => s + (v - mean) ** 2, 0) / (clean.length - 1);
const stddev = Math.sqrt(variance);
return { mean, stddev, cv: stddev / Math.abs(mean), games: clean.length };
}
async function getConsistency(input = {}) {
const { playerName, sport, statType, gameLogs: providedLogs } = input;
const logs = providedLogs || await gameLogService.getGameLogs(playerName, sport, 20);
if (!logs || logs.length < 2) {
return { consistency: 'unknown', score: null, games: logs?.length ?? 0 };
}
const values = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null);
const s = statsFor(values);
if (!s) return { consistency: 'unknown', score: null, games: values.length };
return { ...s, ...classify(s.cv) };
}
module.exports = { getConsistency, classify, statsFor, statFromGameLog };
+183
View File
@@ -0,0 +1,183 @@
/**
* Engine 1 — rule-based grading on the v6b feature vector.
*
* Engine 1 is deterministic. Same inputs always produce the same grade.
* That predictability is intentional: when Engine 2 (LLM, non-deterministic)
* disagrees with Engine 1, the disagreement itself is a signal we surface
* to users — and a stable reference point makes that signal meaningful.
*
* Grade scale (11 steps): F, D, C-, C, C+, B-, B, B+, A-, A, A+
* Start at C (neutral); positive signals push UP, negative push DOWN.
*
* Factors carry the top 3 contributors out so Engine 2 sees them in its
* prompt and the UI can render a "why this grade" tooltip.
*/
const GRADE_SCALE = ['F', 'D', 'C-', 'C', 'C+', 'B-', 'B', 'B+', 'A-', 'A', 'A+'];
const NEUTRAL_INDEX = 3; // 'C'
const GRADE_TO_CONFIDENCE = {
'A+': 1.00,
'A': 0.90,
'A-': 0.80,
'B+': 0.65,
'B': 0.55,
'B-': 0.45,
'C+': 0.35,
'C': 0.25,
'C-': 0.20,
'D': 0.15,
'F': 0.10,
};
function clampIndex(idx) {
return Math.max(0, Math.min(GRADE_SCALE.length - 1, idx));
}
function indexToGrade(idx) {
return GRADE_SCALE[clampIndex(Math.round(idx))];
}
// Each factor produces a delta (positive or negative) plus a label that
// lands in the top-N list. We track magnitude for sorting so the UI can
// surface "this matters most" honestly.
function computeFactors(input) {
const { features = {}, trap = {}, consistency = {}, prop } = input;
const factors = [];
const line = Number(prop?.line);
const direction = prop?.direction;
const overWeighted = direction === 'over';
// Recent form vs the line.
if (Number.isFinite(features.l5_avg) && Number.isFinite(line) && line > 0) {
const delta = (features.l5_avg - line) / line; // fractional gap
if (overWeighted) {
if (delta >= 0.15) factors.push({ label: 'l5_hot_vs_line', delta: 1.0, magnitude: Math.abs(delta) });
else if (delta <= -0.15) factors.push({ label: 'l5_cold_vs_line', delta: -1.0, magnitude: Math.abs(delta) });
} else {
// For UNDER props the signs flip.
if (delta <= -0.15) factors.push({ label: 'l5_under_friendly', delta: 1.0, magnitude: Math.abs(delta) });
else if (delta >= 0.15) factors.push({ label: 'l5_hot_vs_under', delta: -1.0, magnitude: Math.abs(delta) });
}
}
// Trend confirmation from L20.
if (Number.isFinite(features.l20_avg) && Number.isFinite(line) && line > 0) {
const delta20 = (features.l20_avg - line) / line;
if (overWeighted && delta20 > 0) factors.push({ label: 'l20_over_line', delta: 1.0, magnitude: Math.abs(delta20) });
else if (!overWeighted && delta20 < 0) factors.push({ label: 'l20_under_line', delta: 1.0, magnitude: Math.abs(delta20) });
}
// Consistency.
const cLabel = consistency.consistency;
if (cLabel === 'elite' || cLabel === 'reliable') {
factors.push({ label: `consistency_${cLabel}`, delta: 1.0, magnitude: consistency.score ?? 0.7 });
} else if (cLabel === 'boom_bust') {
factors.push({ label: 'consistency_boom_bust', delta: -1.0, magnitude: 0.9 });
}
// Opponent rank (0..1 scale where 1.0 = worst defense, easiest matchup).
if (Number.isFinite(features.opp_rank_stat)) {
if (features.opp_rank_stat >= 0.70) {
const adj = overWeighted ? 1.0 : -1.0;
factors.push({ label: 'weak_opponent_defense', delta: adj, magnitude: features.opp_rank_stat });
} else if (features.opp_rank_stat <= 0.30) {
const adj = overWeighted ? -1.0 : 1.0;
factors.push({ label: 'top_opponent_defense', delta: adj, magnitude: 1 - features.opp_rank_stat });
}
}
// Home / away.
if (features.home_away === 1.0) {
factors.push({ label: 'home_game', delta: 0.5, magnitude: 0.5 });
} else if (features.home_away === 0.0 && features.opp_rank_stat != null && features.opp_rank_stat <= 0.15) {
factors.push({ label: 'away_vs_top5_defense', delta: -0.5, magnitude: 0.7 });
}
// Rest / fatigue.
if (features.rest_days >= 2) factors.push({ label: 'rested_2plus', delta: 0.5, magnitude: 0.5 });
if (features.rest_days === 0) factors.push({ label: 'back_to_back', delta: -0.5, magnitude: 0.7 });
if ((features.game_count_in_7d ?? 0) >= 4) factors.push({ label: 'heavy_workload_7d', delta: -0.5, magnitude: 0.6 });
// Coach pace.
if (Number.isFinite(features.coach_pace_delta) && Math.abs(features.coach_pace_delta) > 0.5) {
const sign = overWeighted ? Math.sign(features.coach_pace_delta) : -Math.sign(features.coach_pace_delta);
factors.push({ label: 'coach_pace_delta', delta: 0.5 * sign, magnitude: Math.abs(features.coach_pace_delta) / 5 });
}
// Ref pace.
if (Number.isFinite(features.ref_pace_adjustment) && Math.abs(features.ref_pace_adjustment) > 0.1) {
const sign = overWeighted ? Math.sign(features.ref_pace_adjustment) : -Math.sign(features.ref_pace_adjustment);
factors.push({ label: 'ref_pace_adjustment', delta: 0.5 * sign, magnitude: Math.abs(features.ref_pace_adjustment) });
}
// Ref foul tendency — a high-foul crew puts FT-heavy scorers at the line
// more often. We treat the magnitude as a binary boost for scoring props.
if (Number.isFinite(features.ref_foul_adjustment)) {
if (features.ref_foul_adjustment > 0.5) {
factors.push({ label: 'ref_foul_high', delta: overWeighted ? 0.5 : -0.5, magnitude: features.ref_foul_adjustment });
} else if (features.ref_foul_adjustment < -0.5) {
factors.push({ label: 'ref_foul_low', delta: overWeighted ? -0.5 : 0.5, magnitude: Math.abs(features.ref_foul_adjustment) });
}
}
// Opponent injury severity — 2-3+ starters out means a thinner rotation
// and easier matchup. Always lifts an OVER, never matters for UNDER.
if (Number.isFinite(features.injury_severity_score) && overWeighted) {
if (features.injury_severity_score >= 3) {
factors.push({ label: 'opp_3plus_starters_out', delta: 1.0, magnitude: 1.0 });
} else if (features.injury_severity_score >= 2) {
factors.push({ label: 'opp_2_starters_out', delta: 0.5, magnitude: 0.7 });
}
}
// Playoff experience — rookies in playoffs are volatile (downgrade);
// veterans handle the spotlight better (upgrade). Only meaningful in
// playoff games (season_type >= 2 in our config).
if (Number.isFinite(features.career_playoff_games) && features.season_type >= 2) {
if (features.career_playoff_games === 0) {
factors.push({ label: 'rookie_in_playoffs', delta: -0.5, magnitude: 0.8 });
} else if (features.career_playoff_games > 30) {
factors.push({ label: 'veteran_in_playoffs', delta: 0.5, magnitude: 0.6 });
}
}
// Trap composite — the big lever.
if (Number.isFinite(trap.composite) && trap.composite > 0.5) {
factors.push({ label: 'trap_composite_high', delta: -1.0, magnitude: trap.composite });
}
return factors;
}
function gradeFromFactors(factors) {
let idx = NEUTRAL_INDEX;
for (const f of factors) idx += f.delta;
idx = clampIndex(Math.round(idx));
return { grade: GRADE_SCALE[idx], confidence: GRADE_TO_CONFIDENCE[GRADE_SCALE[idx]] ?? 0.25 };
}
function topFactorLabels(factors, n = 3) {
return [...factors]
.sort((a, b) => Math.abs(b.delta * b.magnitude) - Math.abs(a.delta * a.magnitude))
.slice(0, n)
.map((f) => f.label);
}
function gradeProp(input) {
const factors = computeFactors(input);
const { grade, confidence } = gradeFromFactors(factors);
return {
grade,
confidence,
top_factors: topFactorLabels(factors, 3),
all_factors: factors.map((f) => f.label),
};
}
module.exports = {
gradeProp,
GRADE_SCALE,
GRADE_TO_CONFIDENCE,
__internals: { computeFactors, gradeFromFactors, topFactorLabels, indexToGrade, NEUTRAL_INDEX },
};
+267
View File
@@ -0,0 +1,267 @@
/**
* Engine 2 — LLM analysis layer on top of Engine 1 grades.
*
* Engine 2 doesn't REPLACE Engine 1. It runs after Engine 1 produces a
* grade for an A/B-tier prop, applies natural-language reasoning over the
* full feature vector + trap signals, and either agrees or disagrees.
* Disagreement is itself a signal — surface it in the UI so users can
* see when our two systems diverge.
*
* Architecture choices:
* - Async + non-blocking. Engine 1 returns immediately; Engine 2 fills
* in 5-30 seconds later via the queue.
* - Queue is in-memory (Map keyed by gradeId). On process restart we
* lose the queue, which is acceptable — n8n can re-queue from
* grade_history WHERE engine2_analyzed_at IS NULL.
* - Only A/B-tier props qualify. C/D/F grades skip Engine 2 entirely;
* they're already flagged as low-confidence and don't need narrative.
* - Prompt is GENERIC — no 'VYNDR' brand string. The model has no idea
* who we are. That keeps our system prompt out of any provider's
* training/QA pipeline.
*/
const openRouter = require('../adapters/openRouterAdapter');
const { getSupabaseServiceClient } = require('../../utils/supabase');
const BATCH_SIZE = Number(process.env.ENGINE2_BATCH_SIZE) || 10;
const ENABLED = String(process.env.ENGINE2_ENABLED || 'true').toLowerCase() !== 'false';
// Grades that qualify for Engine 2 analysis. C/D/F skip.
const ELIGIBLE_GRADES = new Set(['A+', 'A', 'A-', 'B+', 'B', 'B-']);
const VALID_GRADES = new Set([
'A+', 'A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D', 'F', null,
]);
// In-process FIFO queue. Map preserves insertion order — values carry the
// context needed to build the prompt without re-querying upstream.
const queue = new Map();
const SYSTEM_MESSAGE = (
"You are a sports analytics engine analyzing player prop bets. "
+ "Respond ONLY with valid JSON. No preamble, no markdown, no explanation "
+ "outside the JSON structure. If you cannot analyze this prop, respond "
+ 'with { "grade": null, "reason": "insufficient data" }.'
);
function buildPrompt(ctx) {
const features = ctx.features || {};
const trapSignals = ctx.trap?.signals || {};
const recent = ctx.recentGames || [];
const featureLines = Object.entries(features)
.map(([k, v]) => {
if (typeof v === 'number') {
return `${k}: ${Number.isInteger(v) ? v : v.toFixed(2)}`;
}
return `${k}: ${v}`;
})
.join('\n');
const activeTraps = Object.entries(trapSignals)
.filter(([, s]) => s?.active && s?.score > 0)
.map(([name, s]) => `- ${name}: ${s.score.toFixed(2)} (${s.explanation})`)
.join('\n') || 'none';
const recentLines = recent
.map((g) => ` ${g.date}: ${g.value} vs ${g.opponent}${g.home ? ' (home)' : ''}`)
.join('\n') || ' (no recent games)';
return [
`PLAYER: ${ctx.player_name} (${ctx.team || 'unknown'})`,
`SPORT: ${ctx.sport}`,
`PROP: ${ctx.direction} ${ctx.line} ${ctx.stat_type}`,
`GAME: ${ctx.away_team || '?'} @ ${ctx.home_team || '?'}, ${ctx.game_date || '?'}`,
'',
'FEATURES:',
featureLines || ' (no features computed)',
'',
`ENGINE 1 GRADE: ${ctx.engine1_grade} (${(ctx.engine1_factors || []).slice(0, 3).join(', ') || 'no factors'})`,
'',
'TRAP SIGNALS:',
activeTraps,
`Trap composite: ${(ctx.trap?.composite ?? 0).toFixed(2)} (${ctx.trap?.recommendation || 'unknown'})`,
'',
`CONSISTENCY: ${ctx.consistency?.consistency || 'unknown'} (cv=${(ctx.consistency?.cv ?? 0).toFixed(2)}, score=${(ctx.consistency?.score ?? 0).toFixed(2)})`,
'',
...(ctx.probability && Number.isFinite(ctx.probability.p_over) ? [
`PROBABILITY: P(Over) = ${ctx.probability.p_over.toFixed(2)} | P(Under) = ${(1 - ctx.probability.p_over).toFixed(2)}`,
`Components: ${
Object.entries(ctx.probability.components || {})
.filter(([, v]) => Number.isFinite(Number(v)))
.map(([k, v]) => `${k}=${Number(v).toFixed(2)}`)
.join(', ') || 'none'
}`,
'',
] : []),
'RECENT PERFORMANCE:',
recentLines,
'',
'Analyze this prop and respond with:',
'{',
' "grade": "A+/A/A-/B+/B/B-/C+/C/C-/D/F",',
' "confidence": 0.0-1.0,',
' "agrees_with_engine1": true/false,',
' "narrative": "2-3 sentence analysis",',
' "trap_concern": "specific trap risk if any, or null",',
' "key_factor": "single most important factor"',
'}',
].join('\n');
}
// Four-strategy parser. The model is supposed to return raw JSON, but
// "supposed to" is doing a lot of work — we layer fallbacks so a chatty
// model doesn't make us drop the whole analysis. Strategy 4 (regex field
// extraction) is the last-ditch — at least we capture the grade.
function parseResponse(raw) {
if (!raw || typeof raw !== 'string') return null;
// 1. Direct parse.
try {
const j = JSON.parse(raw.trim());
if (j && typeof j === 'object') return j;
} catch { /* fall through */ }
// 2. Markdown fenced block.
const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence?.[1]) {
try {
const j = JSON.parse(fence[1].trim());
if (j && typeof j === 'object') return j;
} catch { /* fall through */ }
}
// 3. First {...} block.
const obj = raw.match(/\{[\s\S]*\}/);
if (obj) {
try {
const j = JSON.parse(obj[0]);
if (j && typeof j === 'object') return j;
} catch { /* fall through */ }
}
// 4. Field-level regex extraction — last resort. We at least want the
// grade letter; the narrative becomes a flag string so the row is
// distinguishable from a model that returned valid JSON.
const gradeMatch = raw.match(/["']?grade["']?\s*[:=]\s*["']?([A-F][+-]?)/i);
if (gradeMatch) {
const confMatch = raw.match(/["']?confidence["']?\s*[:=]\s*([\d.]+)/i);
const conf = confMatch ? parseFloat(confMatch[1]) : NaN;
return {
grade: gradeMatch[1].toUpperCase(),
confidence: Number.isFinite(conf) && conf >= 0 && conf <= 1 ? conf : 0.5,
narrative: 'Extracted from malformed response',
agrees_with_engine1: null,
key_factor: null,
trap_concern: null,
};
}
return null;
}
function validateAnalysis(parsed) {
if (!parsed) return null;
// Allow the explicit "I can't" response.
if (parsed.grade === null) return { grade: null, reason: parsed.reason || 'insufficient data' };
if (!VALID_GRADES.has(parsed.grade)) return null;
const confidence = Number(parsed.confidence);
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) return null;
const narrative = typeof parsed.narrative === 'string' ? parsed.narrative.slice(0, 500) : null;
if (!narrative || narrative.length === 0) return null;
return {
grade: parsed.grade,
confidence,
narrative,
agrees_with_engine1: !!parsed.agrees_with_engine1,
trap_concern: typeof parsed.trap_concern === 'string' ? parsed.trap_concern.slice(0, 300) : null,
key_factor: typeof parsed.key_factor === 'string' ? parsed.key_factor.slice(0, 200) : null,
};
}
function queueAnalysis(gradeId, propContext) {
if (!ENABLED) return;
if (!gradeId || !propContext) return;
if (!ELIGIBLE_GRADES.has(propContext.engine1_grade)) return;
// De-dupe by gradeId — re-queuing on retry is fine; we just overwrite.
queue.set(gradeId, propContext);
}
function getQueueSize() {
return queue.size;
}
function clearQueue() {
queue.clear();
}
async function persistResult(gradeId, analysis, modelUsed, latencyMs) {
const supabase = getSupabaseServiceClient();
const patch = {
engine2_grade: analysis.grade,
engine2_confidence: analysis.confidence,
engine2_narrative: analysis.narrative,
engine2_agrees: analysis.agrees_with_engine1,
engine2_key_factor: analysis.key_factor,
engine2_trap_concern: analysis.trap_concern,
engine2_model: modelUsed,
engine2_latency_ms: latencyMs,
engine2_analyzed_at: new Date().toISOString(),
};
const { error } = await supabase.from('grade_history').update(patch).eq('id', gradeId);
if (error) {
console.warn('[engine2] persist failed for', gradeId, error.message);
}
}
async function analyzeOne(gradeId, propContext) {
const userPrompt = buildPrompt(propContext);
const result = await openRouter.analyze(SYSTEM_MESSAGE, userPrompt);
if (!result) return { ok: false, reason: 'openrouter unavailable' };
const parsed = parseResponse(result.response);
const analysis = validateAnalysis(parsed);
if (!analysis) return { ok: false, reason: 'parse/validate failed' };
if (analysis.grade === null) return { ok: false, reason: analysis.reason };
await persistResult(gradeId, analysis, result.modelUsed, result.latencyMs);
return { ok: true, analysis, modelUsed: result.modelUsed, latencyMs: result.latencyMs };
}
async function processQueue() {
if (!ENABLED) return { processed: 0, succeeded: 0, failed: 0 };
let processed = 0;
let succeeded = 0;
let failed = 0;
for (const [gradeId, ctx] of queue.entries()) {
if (processed >= BATCH_SIZE) break;
queue.delete(gradeId);
processed += 1;
try {
const res = await analyzeOne(gradeId, ctx);
if (res.ok) succeeded += 1; else failed += 1;
} catch (err) {
console.warn('[engine2] analyze threw for', gradeId, err.message);
failed += 1;
}
}
return { processed, succeeded, failed, remaining: queue.size };
}
module.exports = {
queueAnalysis,
processQueue,
getQueueSize,
clearQueue,
__internals: {
buildPrompt,
parseResponse,
validateAnalysis,
analyzeOne,
persistResult,
queue,
SYSTEM_MESSAGE,
ELIGIBLE_GRADES,
VALID_GRADES,
BATCH_SIZE,
},
};
+268
View File
@@ -0,0 +1,268 @@
/**
* Feature cache — the central feature-vector builder for every prop.
*
* Philosophy: features are OMITTED when the underlying data source is
* unavailable, never zeroed. Engine 2 handles variable-length feature
* sets; a zero would lie to the model about what we actually know.
*
* Per-feature TTL categories (Redis):
* game_log: 4h — game logs refresh once per night
* team: 24h — opponent stats are daily
* coach: 30d — coach profiles are rare to change
* ref: 12h — assignments published morning of game day
* injury: 2h — injuries change at shootaround
* line: 2m — line state changes constantly during the day
* context: none — computed on demand (home/away, rest days)
*
* Cache key: features:{sport}:{playerId}:{statType}:{gameId}
* The full vector is cached for 2 minutes so repeated calls during the
* same grading cycle don't recompute. After 2 minutes, individual
* features get refreshed from their own caches.
*/
const { cacheGet, cacheSet } = require('../../utils/redis');
const { getTeamStats, getOpponentRank } = require('./teamStatsCache');
const { getRefImpact } = require('./refSignals');
const { getCoachImpact } = require('./coachSignals');
const { roleValue } = require('./lineupSignals');
const { getTeamInjuries } = require('./injuryParser');
const { getLineMovement } = require('./lineMovement');
const gameLogs = require('./gameLogService');
const VECTOR_TTL_SECONDS = 120;
function avg(values) {
const clean = values.filter((v) => Number.isFinite(v));
if (clean.length === 0) return null;
return clean.reduce((a, b) => a + b, 0) / clean.length;
}
function stddev(values) {
const clean = values.filter((v) => Number.isFinite(v));
if (clean.length < 2) return null;
const mean = avg(clean);
const sq = clean.reduce((sum, v) => sum + (v - mean) ** 2, 0);
return Math.sqrt(sq / (clean.length - 1));
}
// Extract a stat value from a single game-log entry by stat type. Game-log
// rows out of the Python service are keyed by stat name (points,
// rebounds, etc.) and combo stats need to be summed at read time.
function statFromGameLog(row, statType) {
if (!row) return null;
switch (statType) {
case 'pts_reb_ast': {
const s = (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
return s;
}
case 'pts_reb':
return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
case 'pts_ast':
return (Number(row.points) || 0) + (Number(row.assists) || 0);
case 'reb_ast':
return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
case 'stl_blk':
return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
default: {
const v = Number(row[statType]);
return Number.isFinite(v) ? v : null;
}
}
}
function daysBetween(aIso, bIso) {
const ms = new Date(aIso).getTime() - new Date(bIso).getTime();
if (!Number.isFinite(ms)) return null;
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
async function gameLogFeatures(playerName, sport, statType) {
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
if (!logs || logs.length === 0) return {};
const valuesAll = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null);
const l5 = valuesAll.slice(0, 5);
const l20 = valuesAll;
const l10 = valuesAll.slice(0, 10);
const out = {};
const m5 = avg(l5);
const m20 = avg(l20);
const s10 = stddev(l10);
if (m5 != null) out.l5_avg = m5;
if (m20 != null) out.l20_avg = m20;
if (s10 != null) out.l10_stddev = s10;
// Career playoff games is a separate endpoint.
const cp = await gameLogs.getCareerPlayoffGames(playerName, sport);
if (Number.isFinite(cp)) out.career_playoff_games = cp;
return out;
}
async function teamFeatures(sport, opponentAbbr, statType) {
const out = {};
if (!opponentAbbr) return out;
const oppStats = await getTeamStats(sport, opponentAbbr);
if (oppStats) {
if (Number.isFinite(oppStats.pace)) out.pace_factor = oppStats.pace;
if (Number.isFinite(oppStats.pace)) out.team_pace = oppStats.pace;
}
const rank = await getOpponentRank(sport, opponentAbbr, statType);
if (rank != null) out.opp_rank_stat = rank;
return out;
}
function contextFeatures(gameContext = {}) {
const out = {};
if (gameContext.home_away === 'home') out.home_away = 1.0;
else if (gameContext.home_away === 'away') out.home_away = 0.0;
if (Number.isFinite(gameContext.rest_days)) out.rest_days = gameContext.rest_days;
if (Number.isFinite(gameContext.game_count_in_7d)) out.game_count_in_7d = gameContext.game_count_in_7d;
if (gameContext.season_type != null) out.season_type = gameContext.season_type;
if (Number.isFinite(gameContext.game_in_series)) out.game_in_series = gameContext.game_in_series;
if (Number.isFinite(gameContext.season_phase)) out.season_phase = gameContext.season_phase;
return out;
}
async function injuryFeatures(sport, teamId, knownStarterIds = []) {
const out = {};
if (!teamId) return out;
const list = await getTeamInjuries(sport, teamId);
if (!list || list.length === 0) {
out.injury_severity_score = 0;
return out;
}
const starterSet = new Set(knownStarterIds.map(String));
const missingStarters = list.filter(
(i) => starterSet.has(i.playerId) && (i.status === 'OUT' || i.status === 'DOUBTFUL')
);
out.injury_severity_score = Math.min(5, missingStarters.length);
// Teammate-absence bump: a league-average constant when we don't have
// with/without splits for this player. Engine 2 can replace this with
// a learned value over time.
if (missingStarters.length > 0) out.teammate_absence_bump = 0.05 * missingStarters.length;
return out;
}
async function lineFeatures(gameId, playerName, statType) {
const lm = await getLineMovement(gameId, playerName, statType);
if (!lm) return {};
return { line_delta: lm.movement };
}
async function refFeatures(gameId) {
const impact = await getRefImpact(gameId);
if (!impact) return {};
const out = {};
if (Number.isFinite(impact.pace_impact)) out.ref_pace_adjustment = impact.pace_impact;
if (Number.isFinite(impact.foul_adjustment)) out.ref_foul_adjustment = impact.foul_adjustment;
return out;
}
async function coachFeatures(sport, teamAbbr, gameContext = {}) {
const impact = await getCoachImpact(sport, teamAbbr, gameContext);
if (!impact) return {};
const out = {};
if (Number.isFinite(impact.adjusted_pace_delta)) out.coach_pace_delta = impact.adjusted_pace_delta;
if (Number.isFinite(impact.without_primary_pace_shift)) {
out.coach_player_interaction = impact.without_primary_pace_shift;
}
return out;
}
function lineupFeatures(role) {
if (!role) return {};
return { lineup_ball_handler_role: roleValue(role) };
}
// Top-level: build the full vector. Each sub-call is independent so a
// failure in one (e.g. ref assignments not yet published) just omits its
// feature and the rest of the vector is still useful.
async function getFeatures(input = {}) {
const {
playerId,
playerName,
statType,
sport,
teamAbbr,
opponentAbbr,
teamId,
opponentTeamId,
gameId,
gameContext,
role,
knownStarterIds = [],
} = input;
const cacheKey = `features:${sport}:${playerId}:${statType}:${gameId}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
const [gl, team, ctx, injury, line, ref, coach, lineup] = await Promise.all([
gameLogFeatures(playerName, sport, statType),
teamFeatures(sport, opponentAbbr, statType),
Promise.resolve(contextFeatures(gameContext)),
injuryFeatures(sport, teamId, knownStarterIds),
lineFeatures(gameId, playerName, statType),
refFeatures(gameId),
coachFeatures(sport, teamAbbr, gameContext),
Promise.resolve(lineupFeatures(role)),
]);
const features = { ...gl, ...team, ...ctx, ...injury, ...line, ...ref, ...coach, ...lineup };
const FEATURE_NAMES = [
'l5_avg', 'l20_avg', 'l10_stddev', 'career_playoff_games',
'opp_rank_stat', 'pace_factor', 'team_pace',
'home_away', 'rest_days', 'game_count_in_7d', 'season_type', 'game_in_series', 'season_phase',
'teammate_absence_bump', 'primary_stat_suppression', 'injury_severity_score',
'line_delta',
'ref_pace_adjustment', 'ref_foul_adjustment',
'coach_pace_delta', 'coach_player_interaction',
'lineup_ball_handler_role',
];
const available = FEATURE_NAMES.filter((n) => features[n] != null);
const missing = FEATURE_NAMES.filter((n) => features[n] == null);
const payload = {
features,
meta: {
computed_at: new Date().toISOString(),
features_available: available,
features_missing: missing,
},
};
await cacheSet(cacheKey, payload, VECTOR_TTL_SECONDS);
return payload;
}
async function clearCache(cacheKey) {
// Hook for tests + manual invalidation.
const { cacheDel } = require('../../utils/redis');
return cacheDel(cacheKey);
}
function getCacheStats() {
return { ttlSeconds: VECTOR_TTL_SECONDS };
}
module.exports = {
getFeatures,
clearCache,
getCacheStats,
// Internal helpers exported for unit tests + Engine 2 reuse.
__internals: {
gameLogFeatures,
teamFeatures,
contextFeatures,
injuryFeatures,
lineFeatures,
refFeatures,
coachFeatures,
lineupFeatures,
statFromGameLog,
avg,
stddev,
daysBetween,
},
};
@@ -0,0 +1,87 @@
/**
* Game-log service — fetches recent player game logs.
*
* Primary path: the Python FastAPI service at PYTHON_SERVICE_URL (default
* http://localhost:8000). Its /stats/last-n and /wnba/stats/last-n
* endpoints return per-game stat rows.
*
* Secondary path: not implemented in this session. If the Python service
* is unreachable, we return null and let the feature cache omit the
* features that depend on game logs. A flaky stats backend should NOT
* generate fake feature values.
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const PYTHON_BASE = process.env.PYTHON_SERVICE_URL || 'http://localhost:8000';
const CACHE_TTL_SECONDS = 4 * 60 * 60; // 4h — game logs change once per night
const HTTP_TIMEOUT_MS = 15_000;
function pythonPath(sport) {
switch (sport) {
case 'nba': return '/stats/last-n';
case 'wnba': return '/wnba/stats/last-n';
default: return null;
}
}
async function getGameLogs(playerName, sport, count = 20) {
const path = pythonPath(sport);
if (!path) return null;
const cacheKey = `gamelogs:${sport}:${playerName}:${count}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
try {
const res = await axios.get(`${PYTHON_BASE}${path}`, {
params: { player: playerName, n: count },
timeout: HTTP_TIMEOUT_MS,
});
const games = res.data?.games || res.data?.results || [];
if (!Array.isArray(games) || games.length === 0) return null;
await cacheSet(cacheKey, games, CACHE_TTL_SECONDS);
return games;
} catch (err) {
// Python service down or returning 404 — return null, caller omits.
if (err?.response?.status !== 404) {
console.warn(`[gameLog] fetch failed for ${playerName}:`, err?.message);
}
return null;
}
}
// Career playoff games — approximated from the season-avg endpoint's career
// summary, if present. If the Python service doesn't surface this, return
// null and let the caller skip the feature.
async function getCareerPlayoffGames(playerName, sport) {
if (sport !== 'nba' && sport !== 'wnba') return null;
try {
const res = await axios.get(`${PYTHON_BASE}/stats/season-avg`, {
params: { player: playerName, season: 'career' },
timeout: HTTP_TIMEOUT_MS,
});
const games = res.data?.career_playoff_games;
return Number.isFinite(Number(games)) ? Number(games) : null;
} catch {
return null;
}
}
// with/without analysis — compare a player's stats when a specific teammate
// is in vs out. Requires the Python service to expose this; if not, the
// feature falls back to a league-average bump (caller's choice).
async function getWithWithoutStats(playerName, sport, statType, teammateName) {
if (sport !== 'nba' && sport !== 'wnba') return null;
try {
const res = await axios.get(`${PYTHON_BASE}/stats/with-without`, {
params: { player: playerName, stat_type: statType, teammate: teammateName },
timeout: HTTP_TIMEOUT_MS,
});
return res.data || null;
} catch {
return null;
}
}
module.exports = { getGameLogs, getCareerPlayoffGames, getWithWithoutStats };
@@ -0,0 +1,303 @@
/**
* Grading pipeline orchestrator.
*
* Called by n8n at 10:30 AM, 1 PM, 4 PM, 6 PM ET (and on demand from the
* /api/grading/pipeline endpoint). For one sport per call, it:
*
* 1. Pulls today's scoreboard from the sport config's ESPN endpoint.
* We do NOT call SharpAPI for the slate — only for player props per
* game. Scoreboard is the source of truth for which games exist.
* 2. For each game, fetches player props via SharpAPI.
* 3. For each prop, builds a feature vector + trap composite +
* consistency score, then asks Engine 1 to grade.
* 4. Persists the grade to grade_history.
* 5. Queues A/B-tier grades for Engine 2.
* 6. Drains the Engine 2 queue (best-effort, one batch).
*
* Failure semantics:
* - SharpAPI down → 0 props graded, summary still returns.
* - Per-prop error → log + skip, other props continue.
* - Engine 2 queue failure → does not affect Engine 1 grades that
* are already in the database.
*/
const axios = require('axios');
const { getSportConfig } = require('../../config/sports');
const { getSupabaseServiceClient } = require('../../utils/supabase');
const featureCache = require('./featureCache');
const trapDetection = require('./trapDetection');
const consistencyScore = require('./consistencyScore');
const engine1 = require('./engine1');
const engine2 = require('./engine2');
const gameLogService = require('./gameLogService');
const probabilityEstimator = require('./probabilityEstimator');
const sharpApi = require('../adapters/sharpApiAdapter');
const HTTP_TIMEOUT_MS = 15_000;
async function fetchTodaysGames(sportCfg) {
try {
const res = await axios.get(sportCfg.espnScoreboard, { timeout: HTTP_TIMEOUT_MS });
const events = res.data?.events || [];
return events.map((ev) => {
const comp = ev?.competitions?.[0];
const teams = (comp?.competitors || []).reduce((acc, t) => {
const role = t?.homeAway === 'home' ? 'home' : 'away';
acc[role] = { id: t?.id, abbr: t?.team?.abbreviation, name: t?.team?.displayName };
return acc;
}, {});
return {
gameId: String(ev.id),
gameDate: ev?.date,
home: teams.home,
away: teams.away,
state: ev?.status?.type?.state,
};
});
} catch (err) {
console.warn('[orchestrator] scoreboard fetch failed:', err.message);
return [];
}
}
async function buildPropContext(prop, game, sport) {
// Determine whether this prop's player is on home or away team. We
// don't have a roster lookup at this point of the pipeline; the orchestrator
// treats prop.team (if SharpAPI provides) as the canonical, falling back
// to "unknown" for home_away.
const team = prop.team || prop.teamAbbr;
const isHome = team && game.home?.abbr === team;
const opponentAbbr = isHome ? game.away?.abbr : game.home?.abbr;
return {
playerId: prop.playerId || prop.player_id || null,
playerName: prop.player,
statType: prop.statType || prop.stat_type,
sport,
line: Number(prop.line),
direction: prop.direction || 'over',
teamAbbr: team,
opponentAbbr,
gameId: game.gameId,
gameContext: {
home_away: team ? (isHome ? 'home' : 'away') : null,
},
};
}
async function gradeProp(prop, game, sport) {
const ctx = await buildPropContext(prop, game, sport);
// Feature vector — every signal computed in 6b.
const featurePayload = await featureCache.getFeatures({
playerId: ctx.playerId,
playerName: ctx.playerName,
statType: ctx.statType,
sport: ctx.sport,
teamAbbr: ctx.teamAbbr,
opponentAbbr: ctx.opponentAbbr,
gameId: ctx.gameId,
gameContext: ctx.gameContext,
});
const features = featurePayload?.features || {};
// Trap detector — uses features + lineMovement snapshots already in DB.
const trap = await trapDetection.getTrapScore({
playerName: ctx.playerName,
statType: ctx.statType,
sport: ctx.sport,
gameId: ctx.gameId,
gameContext: ctx.gameContext,
features,
odds: { playerLine: ctx.line, consensus: prop.consensus },
});
// Consistency — Engine 2 uses this verbatim in its prompt.
let consistency = { consistency: 'unknown', score: null, games: 0 };
let gameLogs = null;
try {
gameLogs = await gameLogService.getGameLogs(ctx.playerName, ctx.sport, 20);
if (gameLogs && gameLogs.length) {
consistency = await consistencyScore.getConsistency({
playerName: ctx.playerName,
sport: ctx.sport,
statType: ctx.statType,
gameLogs,
});
}
} catch (err) {
console.warn('[orchestrator] consistency failed for', ctx.playerName, err.message);
}
// P(Over) — quantile-based probability from game logs. We pass the same
// game logs to the estimator that consistency uses, so both views agree
// on the same data window. Null if no logs (Python service down).
let probability = { p_over: null, p_under: null, components: {}, reason: 'no_logs' };
if (gameLogs && gameLogs.length) {
probability = probabilityEstimator.estimateProbability({
gameLogs,
line: ctx.line,
statType: ctx.statType,
features,
});
}
// Engine 1 — rule-based, deterministic.
const result = engine1.gradeProp({
features,
trap,
consistency,
prop: { line: ctx.line, direction: ctx.direction },
});
return { ctx, features, trap, consistency, probability, engine1Result: result };
}
async function persistGrade(graded, prop, sport) {
const supabase = getSupabaseServiceClient();
const { ctx, engine1Result, trap, consistency, features, probability } = graded;
const row = {
player_id: ctx.playerId,
player_name: ctx.playerName,
sport,
stat_type: ctx.statType,
line: ctx.line,
direction: ctx.direction,
grade: engine1Result.grade,
projection: Number.isFinite(features.l5_avg) ? features.l5_avg : null,
// modeled_prob is the implied probability from Engine 1's grade tier;
// p_over is the quantile-based probability from game logs. Both useful
// — the former for grade-vs-line edge math, the latter for UI display.
modeled_prob: Number.isFinite(engine1Result?.confidence) ? engine1Result.confidence : null,
implied_prob: null,
p_over: Number.isFinite(probability?.p_over) ? probability.p_over : null,
// factors drive the weight adjuster: each resolved prop's factors get
// nudged based on hit/miss outcome. Stored as JSONB so we can also
// surface them in the UI "why this grade" tooltip.
factors: Array.isArray(engine1Result?.all_factors)
? engine1Result.all_factors
: (Array.isArray(engine1Result?.top_factors) ? engine1Result.top_factors : null),
game_date: new Date().toISOString().slice(0, 10),
game_id: ctx.gameId,
};
const { data, error } = await supabase.from('grade_history').insert(row).select('id').single();
if (error) {
console.warn('[orchestrator] grade_history insert failed:', error.message);
return null;
}
// Hand the gradeId + full context to engine2 so it can build a prompt.
engine2.queueAnalysis(data.id, {
player_name: ctx.playerName,
team: ctx.teamAbbr,
sport,
direction: ctx.direction,
line: ctx.line,
stat_type: ctx.statType,
home_team: prop._home,
away_team: prop._away,
game_date: row.game_date,
engine1_grade: engine1Result.grade,
engine1_factors: engine1Result.top_factors,
features,
trap,
consistency,
probability,
recentGames: [],
});
return data.id;
}
async function gradeProps(props, game, sport) {
const out = [];
for (const prop of props) {
try {
const graded = await gradeProp(prop, game, sport);
const gradeId = await persistGrade(graded, { ...prop, _home: game.home?.name, _away: game.away?.name }, sport);
out.push({ gradeId, grade: graded.engine1Result.grade, prop });
} catch (err) {
console.warn('[orchestrator] gradeProp failed for', prop?.player, err.message);
}
}
return out;
}
async function runPipeline(sport, options = {}) {
const start = Date.now();
let sportCfg;
try { sportCfg = getSportConfig(sport); }
catch (err) { return { error: err.message, sport, games_processed: 0, props_graded: 0, duration_ms: Date.now() - start }; }
const games = await fetchTodaysGames(sportCfg);
if (games.length === 0) {
return { sport, games_processed: 0, props_graded: 0, engine2_queued: 0, errors: 0, duration_ms: Date.now() - start };
}
let propsGraded = 0;
let errors = 0;
let engine2Queued = 0;
for (const game of games) {
let props;
try {
props = await sharpApi.getPlayerProps(sport, game.gameId);
} catch (err) {
console.warn('[orchestrator] sharpApi failed for', game.gameId, err.message);
errors += 1;
continue;
}
if (!Array.isArray(props) || props.length === 0) continue;
const before = engine2.getQueueSize();
const graded = await gradeProps(props, game, sport);
propsGraded += graded.length;
engine2Queued += engine2.getQueueSize() - before;
}
// Drain the Engine 2 queue with a bounded loop. Each processQueue()
// call handles ENGINE2_BATCH_SIZE items, so for slates of ~50+ A/B
// grades one call would leave most of the queue parked. Cap at 5
// iterations (≈50 props per pipeline run with default batch size)
// — beyond that, the next pipeline cycle picks up the remainder.
let engine2Summary = { processed: 0, succeeded: 0, failed: 0, remaining: engine2.getQueueSize() };
if (!options.skipEngine2) {
const MAX_DRAIN_ITERS = 5;
let drainIters = 0;
const totals = { processed: 0, succeeded: 0, failed: 0 };
while (engine2.getQueueSize() > 0 && drainIters < MAX_DRAIN_ITERS) {
const round = await engine2.processQueue();
totals.processed += round.processed || 0;
totals.succeeded += round.succeeded || 0;
totals.failed += round.failed || 0;
drainIters += 1;
// If a round processes 0 items, the queue is stuck (likely
// disabled or all calls failing) — break early instead of looping.
if ((round.processed || 0) === 0) break;
}
engine2Summary = { ...totals, remaining: engine2.getQueueSize(), iterations: drainIters };
}
return {
sport,
games_processed: games.length,
props_graded: propsGraded,
engine2_queued: engine2Queued,
engine2_summary: engine2Summary,
errors,
duration_ms: Date.now() - start,
};
}
function getEngineStatus() {
return {
engine2_queue_size: engine2.getQueueSize(),
adapters_configured: {
sharp_api: sharpApi.configured(),
open_router: require('../adapters/openRouterAdapter').configured(),
},
};
}
module.exports = {
runPipeline,
gradeProps,
gradeProp,
getEngineStatus,
__internals: { fetchTodaysGames, buildPropContext, persistGrade },
};
+157
View File
@@ -0,0 +1,157 @@
/**
* ESPN injury parser.
*
* Two data paths:
* 1. ESPN team-injuries endpoint:
* https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/teams/{teamId}/injuries
* 2. Injury info embedded in scoreboard / summary responses under
* events[i].competitions[0].competitors[t].injuries
*
* We expose three callers:
* getTeamInjuries(sport, teamId) — primary fetch + cache
* getGameInjuries(sport, gameId, espnSummary?) — convenience reading
* the summary JSON the resolution path already loads, so we don't
* refetch
* isPlayerOut / getMissingStarters — derived helpers
*
* Cache: Redis, 2-hour TTL — injuries can change at shootaround on
* game day so we deliberately don't go longer.
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
const HTTP_TIMEOUT_MS = 10_000;
const CACHE_TTL_SECONDS = 2 * 60 * 60;
// ESPN's team-injuries endpoint takes a sport/league path. We resolve the
// league portion off the same SPORT_CONFIG used by the resolution poller
// rather than maintaining a parallel map.
const ESPN_BASE = 'https://site.api.espn.com/apis/site/v2/sports';
const SPORT_PATH = Object.freeze({
nba: 'basketball/nba',
wnba: 'basketball/wnba',
mlb: 'baseball/mlb',
nfl: 'football/nfl',
nhl: 'hockey/nhl',
ncaab: 'basketball/mens-college-basketball',
ncaafb: 'football/college-football',
});
const limiter = createLimiter({ tokensPerInterval: 6, interval: 60_000 });
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
const STATUS_CANON = (status) => {
if (!status) return 'UNKNOWN';
const upper = String(status).toUpperCase();
if (upper.includes('OUT')) return 'OUT';
if (upper.includes('DOUBTFUL')) return 'DOUBTFUL';
if (upper.includes('QUESTIONABLE')) return 'QUESTIONABLE';
if (upper.includes('PROBABLE')) return 'PROBABLE';
if (upper.includes('DAY-TO-DAY') || upper.includes('DAY_TO_DAY') || upper.includes('DTD')) return 'DAY_TO_DAY';
return upper;
};
function normalizeInjuryEntry(entry) {
// ESPN payloads vary — entries may carry the player at `.athlete` or be
// flat with `.name` / `.id`. Try both shapes.
const player = entry?.athlete ?? entry;
return {
playerId: String(player?.id ?? entry?.id ?? ''),
playerName: player?.displayName ?? player?.fullName ?? entry?.name ?? null,
status: STATUS_CANON(entry?.status ?? entry?.type?.description ?? entry?.details?.type),
detail: entry?.details?.detail ?? entry?.shortComment ?? entry?.longComment ?? null,
};
}
async function getTeamInjuries(sport, teamId) {
const path = SPORT_PATH[sport];
if (!path) return [];
const cacheKey = `injuries:${sport}:${teamId}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
await limiter.waitForToken();
try {
const data = await breaker.call(async () => {
const res = await axios.get(`${ESPN_BASE}/${path}/teams/${teamId}/injuries`, {
timeout: HTTP_TIMEOUT_MS,
validateStatus: (s) => (s >= 200 && s < 300) || s === 404,
});
// ESPN returns 404 for teams with no current injuries on some sports
// — that's a clean "no injuries", not an error.
if (res.status === 404) return { injuries: [] };
return res.data;
});
const raw = data?.injuries || data?.athletes || [];
const normalized = (Array.isArray(raw) ? raw : []).map(normalizeInjuryEntry).filter((e) => e.playerName);
await cacheSet(cacheKey, normalized, CACHE_TTL_SECONDS);
return normalized;
} catch (err) {
if (err?.code !== 'CIRCUIT_OPEN') {
console.warn(`[injuries] fetch failed for ${sport}/${teamId}:`, err?.message);
}
return [];
}
}
function extractGameInjuries(espnSummary) {
// espnSummary is the JSON from /summary?event={id}. Some sports nest
// injuries under competitions[0].competitors[t].injuries; others under
// a top-level injuries[] array. We try both.
const out = { home: [], away: [] };
const comp = espnSummary?.header?.competitions?.[0] ?? espnSummary?.competitions?.[0];
if (comp?.competitors) {
for (const team of comp.competitors) {
const bucket = team?.homeAway === 'home' ? 'home' : 'away';
const list = team?.injuries || [];
for (const e of list) {
const normalized = normalizeInjuryEntry(e);
if (normalized.playerName) out[bucket].push(normalized);
}
}
}
if (Array.isArray(espnSummary?.injuries)) {
for (const e of espnSummary.injuries) {
const normalized = normalizeInjuryEntry(e);
if (!normalized.playerName) continue;
const bucket = e?.team === 'home' ? 'home' : 'away';
out[bucket].push(normalized);
}
}
return out;
}
async function getGameInjuries(sport, gameId, espnSummary) {
if (espnSummary) return extractGameInjuries(espnSummary);
// Without a summary in hand, we'd need both team IDs from the scoreboard
// — defer to the caller to pass espnSummary so we don't multiply ESPN
// requests.
return { home: [], away: [] };
}
async function isPlayerOut(sport, teamId, playerId) {
const list = await getTeamInjuries(sport, teamId);
const match = list.find((i) => i.playerId === String(playerId));
if (!match) return false;
return match.status === 'OUT' || match.status === 'DOUBTFUL';
}
// starterIds is an iterable of ESPN player IDs known to start for this team
// (resolved upstream from player_id_map or yesterday's box score).
async function getMissingStarters(sport, teamId, starterIds) {
const injuries = await getTeamInjuries(sport, teamId);
const starterSet = new Set([...starterIds].map(String));
return injuries.filter(
(i) => starterSet.has(i.playerId) && (i.status === 'OUT' || i.status === 'DOUBTFUL')
);
}
module.exports = {
getTeamInjuries,
getGameInjuries,
isPlayerOut,
getMissingStarters,
__internals: { limiter, breaker, normalizeInjuryEntry, STATUS_CANON },
};
+134
View File
@@ -0,0 +1,134 @@
/**
* Line movement signals built on top of line_snapshots.
*
* Two derived signals power the trap detector:
*
* reverseLineMovement
* The line moved AGAINST where the public is betting. If the public is
* hammering OVER but the line drops (toward UNDER), sharp money is on
* the under and the over is a trap.
*
* juiceDegradation
* The line didn't move but the vig on one side got worse (e.g. -110 →
* -130). Books are charging more for the same number — that side is
* the trap.
*
* Both signals require at least two snapshots. If snapshots are missing we
* return null so trap detection can mark the signal "inactive" instead of
* scoring zero (which would dilute the composite).
*/
const { getSupabaseServiceClient } = require('../../utils/supabase');
const { oddsToImplied } = require('../../utils/odds');
async function fetchSnapshots(gameId, playerName, statType) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('line_snapshots')
.select('line, over_odds, under_odds, consensus_median, snapshot_at')
.eq('game_id', gameId)
.eq('stat_type', statType)
.eq('player_name', playerName)
.order('snapshot_at', { ascending: true });
if (error) {
console.warn('[lineMovement] snapshot lookup failed:', error.message);
return [];
}
return data || [];
}
async function getLineMovement(gameId, playerName, statType) {
const snaps = await fetchSnapshots(gameId, playerName, statType);
if (snaps.length < 2) return null;
const open = snaps[0];
const close = snaps[snaps.length - 1];
const movement = Number(close.line) - Number(open.line);
const overJuiceOpen = Number(open.over_odds);
const overJuiceClose = Number(close.over_odds);
const underJuiceOpen = Number(open.under_odds);
const underJuiceClose = Number(close.under_odds);
return {
opening_line: Number(open.line),
current_line: Number(close.line),
movement,
direction: movement > 0 ? 'up' : movement < 0 ? 'down' : 'flat',
opening_over_odds: Number.isFinite(overJuiceOpen) ? overJuiceOpen : null,
current_over_odds: Number.isFinite(overJuiceClose) ? overJuiceClose : null,
opening_under_odds: Number.isFinite(underJuiceOpen) ? underJuiceOpen : null,
current_under_odds: Number.isFinite(underJuiceClose) ? underJuiceClose : null,
juice_change_over: Number.isFinite(overJuiceClose - overJuiceOpen) ? overJuiceClose - overJuiceOpen : null,
juice_change_under: Number.isFinite(underJuiceClose - underJuiceOpen) ? underJuiceClose - underJuiceOpen : null,
snapshots_count: snaps.length,
first_seen: open.snapshot_at,
last_seen: close.snapshot_at,
};
}
// publicBetPct is the public-money percentage on the OVER (0-100). If we
// don't have it, we estimate from odds movement direction: when the over
// got more expensive (smaller positive / bigger negative), the public was
// on the over.
async function reverseLineMovement(gameId, playerName, statType, publicBetPct) {
const lm = await getLineMovement(gameId, playerName, statType);
if (!lm) return null;
// Estimate public side if not provided.
let publicSide;
if (Number.isFinite(publicBetPct)) {
publicSide = publicBetPct >= 50 ? 'over' : 'under';
} else if (Number.isFinite(lm.juice_change_over)) {
// If over juice got worse (became more negative), book is shading away
// from over — public was on over.
publicSide = lm.juice_change_over < 0 ? 'over' : 'under';
} else {
return null;
}
// Line movement direction tells us where sharp money went.
const lineDirection = lm.movement > 0 ? 'over' : lm.movement < 0 ? 'under' : 'flat';
if (lineDirection === 'flat') return null;
const isReverse = publicSide !== lineDirection;
if (!isReverse) return { score: 0, isReverse: false, publicSide, lineDirection };
// Magnitude normalized to typical movement (1 point is meaningful for
// basketball points; everything bigger gets capped at 1.0).
const magnitude = Math.min(Math.abs(lm.movement), 1.0);
const publicWeight = Number.isFinite(publicBetPct)
? Math.max(0.5, Math.abs(publicBetPct - 50) / 50)
: 0.6;
return {
score: Math.min(1.0, magnitude * publicWeight),
isReverse: true,
publicSide,
lineDirection,
movement: lm.movement,
};
}
async function juiceDegradation(gameId, playerName, statType) {
const lm = await getLineMovement(gameId, playerName, statType);
if (!lm) return null;
// Only meaningful when the line itself barely moved — if both line and
// juice shifted, that's regular line movement, captured by RLM instead.
if (Math.abs(lm.movement) > 0.5) return { score: 0, applicable: false };
const overShift = Number.isFinite(lm.juice_change_over) ? lm.juice_change_over : 0;
const underShift = Number.isFinite(lm.juice_change_under) ? lm.juice_change_under : 0;
// Worst-side degradation: the side whose implied-prob increase is bigger
// is the one the books are pulling money to.
const overImpliedShift = (oddsToImplied(lm.current_over_odds) ?? 0) - (oddsToImplied(lm.opening_over_odds) ?? 0);
const underImpliedShift = (oddsToImplied(lm.current_under_odds) ?? 0) - (oddsToImplied(lm.opening_under_odds) ?? 0);
const worstSide = overImpliedShift >= underImpliedShift ? 'over' : 'under';
// Normalize to a 20-cent (e.g. -110 → -130) max move.
const magnitude = Math.max(Math.abs(overShift), Math.abs(underShift));
return {
score: Math.min(1.0, magnitude / 20),
applicable: true,
worstSide,
overShift,
underShift,
};
}
module.exports = { getLineMovement, reverseLineMovement, juiceDegradation, fetchSnapshots };
@@ -0,0 +1,80 @@
/**
* Lineup / role signals.
*
* Two derived inputs:
* getProjectedStarters: from ESPN summary (post-game or pregame) or
* yesterday's box score as a fallback. The poller already caches the
* summary; we just walk it.
* getLineupRole: maps a player to 'primary_handler' | 'secondary' |
* 'role_player' based on usage signals. For now this is a coarse
* heuristic driven by usage_rate; the feature cache pulls a finer
* value once Engine 2 surfaces per-player usage.
*/
function rolesFromBoxScore(boxScore) {
const home = [];
const away = [];
const teams = boxScore?.boxscore?.players || [];
for (let i = 0; i < teams.length; i += 1) {
const team = teams[i];
const bucket = i === 0 ? home : away;
const athletes = team?.statistics?.[0]?.athletes || [];
for (const a of athletes) {
if (!a?.starter) continue;
const id = a?.athlete?.id || a?.id;
const name = a?.athlete?.displayName || a?.athlete?.fullName;
if (!id || !name) continue;
bucket.push({
playerId: String(id),
name,
position: a?.athlete?.position?.abbreviation ?? null,
});
}
}
return { home, away };
}
async function getProjectedStarters(sport, gameId, espnSummary) {
if (!espnSummary) return { home: [], away: [] };
const lineup = rolesFromBoxScore(espnSummary);
// Add 'role' annotation — first starter on each side defaults to primary
// handler. Once usage data is available we refine; for now this is the
// ESPN-listed starting order.
for (const side of ['home', 'away']) {
lineup[side] = lineup[side].map((p, idx) => ({
...p,
role: idx === 0 ? 'primary_handler' : idx <= 2 ? 'secondary' : 'role_player',
}));
}
return lineup;
}
// Coarse classification from a precomputed usage rate (0-1). Caller has
// the rate via teamStatsCache or the Python game-log service.
function classifyByUsage(usageRate) {
const u = Number(usageRate);
if (!Number.isFinite(u)) return 'role_player';
if (u >= 0.28) return 'primary_handler';
if (u >= 0.18) return 'secondary';
return 'role_player';
}
function roleValue(role) {
if (role === 'primary_handler') return 1.0;
if (role === 'secondary') return 0.5;
return 0.0;
}
async function getLineupRole(_sport, _teamAbbr, _playerId, usageRate) {
// Until usage rates feed in, the caller passes one explicitly. If they
// don't, classifyByUsage returns 'role_player' (the safe default).
return classifyByUsage(usageRate);
}
module.exports = {
getProjectedStarters,
getLineupRole,
classifyByUsage,
roleValue,
rolesFromBoxScore,
};
@@ -0,0 +1,125 @@
/**
* P(Over) — estimated probability that the player goes over the line.
*
* This is the *quantile-based* probability we surface to users ("73%
* chance over") and feed into Engine 2's prompt. It is NOT the implied
* probability from the book — that's odds-derived and includes vig. This
* one is from the player's actual distribution.
*
* Formula layers:
* 1. Base — empirical frequency of stat > line across the sample
* 2. Recency — last 5 games weighted 2× to capture trend
* 3. Opponent — bump for weak D, fade for top D (uses 0..1 opp_rank_stat)
* 4. Home / away — +1.5% / -1.5%
* 5. Consistency — volatile players get pulled toward 0.50
*
* Clamp at [0.10, 0.95] — we never claim certainty in either direction.
*/
const CV_VOLATILE_THRESHOLD = 0.40;
const PROB_FLOOR = 0.10;
const PROB_CEIL = 0.95;
function statFromRow(row, statType) {
if (!row) return null;
switch (statType) {
case 'pts_reb_ast':
return (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
case 'pts_reb':
return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
case 'pts_ast':
return (Number(row.points) || 0) + (Number(row.assists) || 0);
case 'reb_ast':
return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
case 'stl_blk':
return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
default: {
const v = Number(row[statType]);
return Number.isFinite(v) ? v : null;
}
}
}
function frequencyOver(values, line) {
const decisive = values.filter((v) => v !== line); // push games don't count
if (decisive.length === 0) return null;
const over = decisive.filter((v) => v > line).length;
return over / decisive.length;
}
function clamp(p) {
return Math.max(PROB_FLOOR, Math.min(PROB_CEIL, p));
}
function estimateProbability({ gameLogs = [], line, statType, features = {} } = {}) {
if (!Array.isArray(gameLogs) || gameLogs.length === 0 || !Number.isFinite(Number(line))) {
return { p_over: null, p_under: null, components: {}, reason: 'insufficient_data' };
}
const numericLine = Number(line);
const values = gameLogs.map((r) => statFromRow(r, statType)).filter((v) => v != null);
if (values.length === 0) {
return { p_over: null, p_under: null, components: {}, reason: 'no_stat_values' };
}
const base = frequencyOver(values, numericLine);
if (base == null) return { p_over: null, p_under: null, components: {}, reason: 'all_pushes' };
// Recency: last 5 games count 2× in a weighted blend.
const recent = values.slice(0, Math.min(5, values.length));
const recencyRate = frequencyOver(recent, numericLine);
const weighted = recencyRate != null
? 0.6 * base + 0.4 * recencyRate
: base;
let p = weighted;
// Opponent adjustment using 0..1 normalized rank.
// opp_rank_stat ≥ 0.70 → weak defense, bump toward over
// opp_rank_stat ≤ 0.30 → strong defense, fade
const oppAdj = (() => {
const r = Number(features.opp_rank_stat);
if (!Number.isFinite(r)) return 0;
if (r >= 0.70) return +0.03;
if (r <= 0.30) return -0.03;
return 0;
})();
p += oppAdj;
const homeAdj = features.home_away === 1.0 ? +0.015 : features.home_away === 0.0 ? -0.015 : 0;
p += homeAdj;
// Consistency pull: volatile players are uncertain — drag p toward 0.50.
const cv = Number(features.l10_stddev) > 0 && Number(features.l20_avg) > 0
? Number(features.l10_stddev) / Number(features.l20_avg)
: null;
const consistencyAdj = (() => {
if (!Number.isFinite(cv)) return null;
if (cv > CV_VOLATILE_THRESHOLD) {
// p' = p * 0.9 + 0.5 * 0.1
const before = p;
p = p * 0.9 + 0.05;
return p - before;
}
return 0;
})();
const pOver = clamp(p);
return {
p_over: pOver,
p_under: 1 - pOver,
components: {
base,
recency: recencyRate,
weighted,
opp_adjustment: oppAdj,
home_adjustment: homeAdj,
consistency_adjustment: consistencyAdj,
cv,
},
};
}
module.exports = {
estimateProbability,
__internals: { statFromRow, frequencyOver, clamp, CV_VOLATILE_THRESHOLD, PROB_FLOOR, PROB_CEIL },
};
+115
View File
@@ -0,0 +1,115 @@
/**
* Referee impact signal.
*
* Game-day ref assignments live in `game_ref_assignments` (migration 017).
* Per-referee tendencies live in `ref_profiles`, populated by the
* Sports-Reference scraper (scripts/scrape-sports-reference.js).
*
* Crew impact is computed by averaging the three refs' profiles. If any
* profile is missing we still return a partial impact (averaging only the
* available refs) — the feature cache decides whether to surface the
* feature or omit it based on coverage.
*/
const { getSupabaseServiceClient } = require('../../utils/supabase');
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
async function getRefAssignment(gameId) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('game_ref_assignments')
.select('ref1_name, ref2_name, ref3_name, ref_crew_avg_fouls, ref_crew_pace_impact')
.eq('game_id', gameId)
.maybeSingle();
if (error) {
console.warn('[refSignals] assignment lookup failed:', error.message);
return null;
}
return data || null;
}
async function getRefProfiles(refNames) {
const named = refNames.filter(Boolean);
if (named.length === 0) return [];
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('ref_profiles')
.select('ref_name, avg_fouls_per_game, avg_free_throws_per_game, pace_impact, home_whistle_bias')
.in('ref_name', named);
if (error) {
console.warn('[refSignals] profile lookup failed:', error.message);
return [];
}
return data || [];
}
function average(values) {
const clean = values.filter((v) => Number.isFinite(v));
if (clean.length === 0) return null;
return clean.reduce((a, b) => a + b, 0) / clean.length;
}
async function getRefImpact(gameId) {
const assignment = await getRefAssignment(gameId);
if (!assignment) return null;
const crew = [assignment.ref1_name, assignment.ref2_name, assignment.ref3_name].filter(Boolean);
if (crew.length === 0) return null;
// If precomputed crew values exist on the assignment row (scraper wrote
// them), prefer those — they were derived from the same profiles but
// baked at assignment time. Note: Number(null) === 0 is finite, so guard
// explicitly against null/undefined before going through Number().
const hasFouls = assignment.ref_crew_avg_fouls != null && Number.isFinite(Number(assignment.ref_crew_avg_fouls));
const hasPace = assignment.ref_crew_pace_impact != null && Number.isFinite(Number(assignment.ref_crew_pace_impact));
if (hasFouls || hasPace) {
return {
crew,
avg_fouls: assignment.ref_crew_avg_fouls,
pace_impact: assignment.ref_crew_pace_impact,
foul_adjustment: assignment.ref_crew_avg_fouls,
home_bias: null,
profilesUsed: crew.length,
};
}
const profiles = await getRefProfiles(crew);
return {
crew,
avg_fouls: average(profiles.map((p) => p.avg_fouls_per_game)),
pace_impact: average(profiles.map((p) => p.pace_impact)),
foul_adjustment: average(profiles.map((p) => p.avg_free_throws_per_game)),
home_bias: average(profiles.map((p) => p.home_whistle_bias)),
profilesUsed: profiles.length,
};
}
// Manual entry endpoint helper — the route module (not built here) calls
// this when ops POSTs an assignment.
async function setRefAssignment(gameId, sport, gameDate, refs) {
const supabase = getSupabaseServiceClient();
// Pull profiles synchronously to precompute crew impact at insert time so
// downstream reads don't need a join.
const profiles = await getRefProfiles(refs);
const avgFouls = average(profiles.map((p) => p.avg_fouls_per_game));
const paceImpact = average(profiles.map((p) => p.pace_impact));
const { error } = await supabase
.from('game_ref_assignments')
.upsert({
game_id: gameId,
sport,
game_date: gameDate,
ref1_name: refs[0] || null,
ref2_name: refs[1] || null,
ref3_name: refs[2] || null,
ref_crew_avg_fouls: avgFouls,
ref_crew_pace_impact: paceImpact,
}, { onConflict: 'game_id' });
if (error) {
console.warn('[refSignals] assignment upsert failed:', error.message);
return { ok: false, error: error.message };
}
return { ok: true, avg_fouls: avgFouls, pace_impact: paceImpact };
}
module.exports = { getRefImpact, getRefAssignment, getRefProfiles, setRefAssignment, LOOPBACK_IPS };
+231
View File
@@ -0,0 +1,231 @@
/**
* Team stats cache — daily refresh, Redis-backed.
*
* Source priority for each sport:
* nba / wnba / ncaab : ESPN team statistics endpoint
* mlb : ESPN + MLB Stats API team totals
* nfl / ncaafb : ESPN + CFBD talent composite (college)
* nhl : ESPN team statistics endpoint
*
* The cache key is `team_stats:{sport}:{teamAbbr}` with a 24h TTL. The
* refresh function (called from n8n or app startup) walks every team in
* the sport and writes one cache entry per team. Rate-limited at 1
* request per 2 seconds to be respectful to ESPN.
*
* Per-team payload normalizes into a uniform shape; values not available
* for a sport are simply omitted (mirrors the feature-cache philosophy).
*
* {
* offensive_rating, defensive_rating, pace, opponent_ppg,
* team_fg_pct, team_3pt_pct, team_ft_rate,
* opponent_fg_pct, opponent_3pt_pct,
* team_k_rate, // MLB only
* defensive_rank, // 1-N (1 = best D)
* by_stat: { points: { allowed: N, rank: 1-30 }, ... }
* }
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
const ESPN_BASE = 'https://site.api.espn.com/apis/site/v2/sports';
const CACHE_TTL_SECONDS = 24 * 60 * 60;
const HTTP_TIMEOUT_MS = 10_000;
const SPORT_PATH = Object.freeze({
nba: 'basketball/nba',
wnba: 'basketball/wnba',
mlb: 'baseball/mlb',
nfl: 'football/nfl',
nhl: 'hockey/nhl',
ncaab: 'basketball/mens-college-basketball',
ncaafb: 'football/college-football',
});
const limiter = createLimiter({ tokensPerInterval: 30, interval: 60_000 }); // 1/2s
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
function teamCacheKey(sport, teamAbbr) {
return `team_stats:${sport}:${String(teamAbbr).toUpperCase()}`;
}
// Pull a numeric value out of ESPN's labeled statistics arrays. ESPN
// returns categories with .stats[] of { name, value, displayValue, abbreviation }.
function pickStat(categoryStats, name) {
if (!Array.isArray(categoryStats)) return null;
const match = categoryStats.find(
(s) =>
(s?.name || '').toLowerCase() === name.toLowerCase()
|| (s?.abbreviation || '').toLowerCase() === name.toLowerCase()
);
if (!match) return null;
const v = Number(match.value);
return Number.isFinite(v) ? v : null;
}
function flattenTeamStats(payload) {
// ESPN returns: { team, season, splits: [...], stats: [...] } depending on
// endpoint. Most commonly: payload.results.stats[]/categories[] for
// /teams/{id}/statistics
const buckets = payload?.results?.stats || payload?.stats || [];
const all = [];
for (const b of buckets) {
if (Array.isArray(b?.stats)) all.push(...b.stats);
if (Array.isArray(b?.splits)) {
for (const split of b.splits) {
if (Array.isArray(split?.stats)) all.push(...split.stats);
}
}
}
return all;
}
function normalizeBasketball(payload) {
const all = flattenTeamStats(payload);
return {
offensive_rating: pickStat(all, 'offensiveRating') ?? pickStat(all, 'oRtg'),
defensive_rating: pickStat(all, 'defensiveRating') ?? pickStat(all, 'dRtg'),
pace: pickStat(all, 'pace'),
opponent_ppg: pickStat(all, 'avgPointsAgainst') ?? pickStat(all, 'oppPPG'),
team_fg_pct: pickStat(all, 'fieldGoalPct'),
team_3pt_pct: pickStat(all, 'threePointFieldGoalPct') ?? pickStat(all, 'threePtPct'),
team_ft_rate: pickStat(all, 'freeThrowAttemptRate'),
opponent_fg_pct: pickStat(all, 'opponentFieldGoalPct'),
opponent_3pt_pct: pickStat(all, 'opponentThreePointFieldGoalPct'),
};
}
function normalizeMlb(payload) {
const all = flattenTeamStats(payload);
return {
team_k_rate: pickStat(all, 'strikeOutRate') ?? pickStat(all, 'strikeoutsPerNine'),
opponent_ppg: pickStat(all, 'runsAgainst'),
};
}
function normalizeFootball(payload) {
const all = flattenTeamStats(payload);
return {
offensive_rating: pickStat(all, 'totalPoints'),
defensive_rating: pickStat(all, 'pointsAgainst'),
opponent_ppg: pickStat(all, 'avgPointsAgainst'),
};
}
function normalize(sport, payload) {
switch (sport) {
case 'nba':
case 'wnba':
case 'ncaab':
return normalizeBasketball(payload);
case 'mlb':
return normalizeMlb(payload);
case 'nfl':
case 'ncaafb':
return normalizeFootball(payload);
case 'nhl':
default:
return flattenTeamStats(payload).reduce((acc, s) => {
if (s?.name && Number.isFinite(Number(s.value))) acc[s.name] = Number(s.value);
return acc;
}, {});
}
}
async function fetchTeamStatsRaw(sport, teamId) {
const path = SPORT_PATH[sport];
if (!path) return null;
await limiter.waitForToken();
return breaker.call(async () => {
const res = await axios.get(`${ESPN_BASE}/${path}/teams/${teamId}/statistics`, {
timeout: HTTP_TIMEOUT_MS,
});
return res.data;
});
}
async function listTeams(sport) {
const path = SPORT_PATH[sport];
if (!path) return [];
await limiter.waitForToken();
const res = await axios.get(`${ESPN_BASE}/${path}/teams`, { timeout: HTTP_TIMEOUT_MS });
const groups = res.data?.sports?.[0]?.leagues?.[0]?.teams || [];
return groups
.map((t) => t?.team)
.filter(Boolean)
.map((t) => ({ id: String(t.id), abbr: t.abbreviation, name: t.displayName }));
}
async function refreshTeamStats(sport) {
const teams = await listTeams(sport);
// Two-pass: fetch every team's stats first, then rank across the league
// so we can normalize opponent rank to 0..1. A raw defensive_rating
// means different things across sports (NBA ~100-120, NHL ~2.5-3.5
// goals/game), so the cache stores both: raw + normalized.
const fetched = [];
let captured = 0;
let errored = 0;
for (const team of teams) {
try {
const raw = await fetchTeamStatsRaw(sport, team.id);
if (!raw) { errored += 1; continue; }
const stats = normalize(sport, raw);
fetched.push({ team, stats });
captured += 1;
} catch (err) {
if (err?.code !== 'CIRCUIT_OPEN') {
console.warn(`[teamStats] ${sport}/${team.abbr} failed: ${err?.message}`);
}
errored += 1;
}
}
// Rank teams by defensive_rating ascending (lower allowed = better D).
// Then map each team's rank to [0, 1] — 0 = best D (hardest matchup),
// 1 = worst D (easiest matchup). The feature cache uses this directly.
const withDef = fetched.filter((f) => Number.isFinite(Number(f.stats.defensive_rating)));
withDef.sort((a, b) => Number(a.stats.defensive_rating) - Number(b.stats.defensive_rating));
const total = withDef.length;
for (let i = 0; i < withDef.length; i += 1) {
withDef[i].stats.defensive_rank_normalized = total > 1 ? i / (total - 1) : 0.5;
}
for (const { team, stats } of fetched) {
await cacheSet(
teamCacheKey(sport, team.abbr),
{ ...stats, team_id: team.id, team_name: team.name },
CACHE_TTL_SECONDS,
);
}
return { captured, errored, total: teams.length };
}
async function getTeamStats(sport, teamAbbr) {
return cacheGet(teamCacheKey(sport, teamAbbr));
}
// Returns the opponent's normalized defensive rank on a 0..1 scale.
// 0.0 = best defense in the league (hardest matchup)
// 1.0 = worst defense (easiest matchup)
// Comparable across sports — NBA, NHL, NFL all collapse to the same
// scale even though their raw defensive_rating values differ by orders
// of magnitude. Returns null when we have no cache entry yet.
async function getOpponentRank(sport, teamAbbr, _statType) {
const stats = await getTeamStats(sport, teamAbbr);
if (!stats) return null;
if (Number.isFinite(Number(stats.defensive_rank_normalized))) {
return Number(stats.defensive_rank_normalized);
}
// Backward-compat: if the cache predates the normalization upgrade, we
// can't normalize a single-team read in isolation — return null and
// let the feature cache omit the feature rather than emit a raw value.
return null;
}
module.exports = {
refreshTeamStats,
getTeamStats,
getOpponentRank,
__internals: { listTeams, normalize, teamCacheKey, limiter, breaker },
};
+259
View File
@@ -0,0 +1,259 @@
/**
* Trap detection — 7 independent signals → one composite trap score.
*
* reverse_line_movement — sharp money moved AGAINST public side
* historical_hit_rate_paradox — high hit-rate AND line moving against them
* new_context_trap — first game in a new context (playoffs G1)
* recency_inflation — L5 dramatically above L20 (chasing hot)
* juice_degradation — vig got worse while line stayed flat
* teammate_return_trap — key teammate returning from injury
* line_consensus_divergence — one book's line ≠ the consensus
*
* Composite formula:
* composite = average(active_signal_scores)
*
* Only ACTIVE signals (the ones with enough data to compute) average in.
* A null/inactive signal does NOT dilute the score — this prevents thin
* data from producing an artificially-low trap score on new deployments.
*
* < 0.25 → proceed
* < 0.50 → caution
* ≥ 0.50 → avoid
*/
const { reverseLineMovement, juiceDegradation, getLineMovement } = require('./lineMovement');
const { getSupabaseServiceClient } = require('../../utils/supabase');
function inactive(reason) {
return { score: 0, active: false, explanation: reason };
}
// Normalize player names for matching across data sources. ParlayAPI may
// emit "Brunson, Jalen" while ESPN emits "Jalen Brunson" — strip case,
// punctuation, suffixes, and collapse whitespace so equivalence works.
function normalizeName(name) {
if (!name) return '';
return String(name)
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/\b(jr|sr|ii|iii|iv|v)\.?\b/g, '')
.replace(/[^a-z\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Detect whether a key teammate transitioned from OUT in the recent past
// to AVAILABLE now. Called by the orchestrator (Section 2) before invoking
// the trap detector — the orchestrator owns the injury-history context.
// priorInjuriesByGame is an array of injury snapshots (most recent first).
// Each entry: array of { playerId, status }. Returns the highest-usage
// teammate that has flipped from OUT/DOUBTFUL to PROBABLE/active, or null.
function detectReturningTeammate(currentInjuries, priorInjuriesByGame, usageMap = {}) {
if (!Array.isArray(priorInjuriesByGame) || priorInjuriesByGame.length === 0) return null;
const currentOutIds = new Set(
(currentInjuries || [])
.filter((i) => i.status === 'OUT' || i.status === 'DOUBTFUL')
.map((i) => String(i.playerId)),
);
// A player was "previously out" if they appeared as OUT/DOUBTFUL in any
// of the last 1-3 games' snapshots.
const priorOutIds = new Set();
for (const snap of priorInjuriesByGame.slice(0, 3)) {
for (const inj of snap || []) {
if (inj.status === 'OUT' || inj.status === 'DOUBTFUL') {
priorOutIds.add(String(inj.playerId));
}
}
}
let best = null;
for (const id of priorOutIds) {
if (currentOutIds.has(id)) continue;
const usage = Number(usageMap[id]) || 0;
if (!best || usage > best.usage) best = { playerId: id, usage };
}
return best;
}
// 1. Reverse line movement.
async function signalReverseLineMovement(input) {
const { gameId, playerName, statType, publicBetPct } = input;
if (!gameId || !playerName || !statType) return inactive('missing inputs');
const r = await reverseLineMovement(gameId, playerName, statType, publicBetPct);
if (!r) return inactive('not enough snapshots');
if (!r.isReverse) return { score: 0, active: true, explanation: 'line moved with public' };
return {
score: r.score,
active: true,
explanation: `line moved toward ${r.lineDirection} while public was on ${r.publicSide}`,
};
}
// 2. Historical hit-rate paradox — high hit rate AND line moving against the
// player. Uses resolution_results history. Confidence-scaled: thin history
// gets a proportional penalty.
async function signalHistoricalHitRateParadox(input) {
const { playerName, statType, sport, gameId } = input;
if (!playerName || !statType || !sport) return inactive('missing inputs');
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('resolution_results')
.select('result, direction, line')
.eq('sport', sport)
.eq('stat_type', statType)
.eq('player_name', playerName);
if (error || !data || data.length < 10) {
return inactive(`only ${data?.length ?? 0} historical resolves`);
}
const hits = data.filter((r) => r.result === 'hit').length;
const hitRate = hits / data.length;
const lm = gameId ? await getLineMovement(gameId, playerName, statType) : null;
if (!lm) return inactive('no line movement context');
// "Against direction" — if the player generally bets OVER and the line
// moves DOWN, that's a trap; flip for UNDER.
const directionGuess = data.filter((r) => r.direction === 'over').length >= data.length / 2 ? 'over' : 'under';
const againstDirection = (directionGuess === 'over' && lm.movement < 0)
|| (directionGuess === 'under' && lm.movement > 0);
if (!againstDirection) return { score: 0, active: true, explanation: 'line moving with the player\'s usual side' };
const confidence = Math.min(data.length / 20, 1.0);
const score = Math.min(1.0, hitRate * Math.abs(lm.movement)) * confidence;
return {
score,
active: true,
explanation: `hit rate ${(hitRate * 100).toFixed(0)}% (${data.length} resolves) but line moved ${lm.movement} against ${directionGuess}`,
};
}
// 3. New context trap — first game in a context where stats may not transfer.
function signalNewContextTrap(input) {
const { gameContext = {} } = input;
let flags = 0;
const reasons = [];
if (gameContext.game_in_series === 1) { flags += 1; reasons.push('series_g1'); }
if (gameContext.first_playoff_game) { flags += 1; reasons.push('first_playoff_game'); }
if (gameContext.new_opponent_in_series) { flags += 1; reasons.push('new_opponent_in_series'); }
if (gameContext.new_venue) { flags += 1; reasons.push('new_venue'); }
if (flags === 0) return inactive('no context flags');
return {
score: flags / 4,
active: true,
explanation: `new context: ${reasons.join(', ')}`,
};
}
// 4. Recency inflation — L5 dramatically above L20.
function signalRecencyInflation(input) {
const f = input.features || {};
const l5 = Number(f.l5_avg);
const l20 = Number(f.l20_avg);
if (!Number.isFinite(l5) || !Number.isFinite(l20) || l20 <= 0) {
return inactive('l5_avg or l20_avg missing');
}
const ratio = (l5 - l20) / l20;
if (ratio <= 0) return { score: 0, active: true, explanation: 'L5 not hotter than L20' };
return {
score: Math.min(1.0, ratio),
active: true,
explanation: `L5 (${l5.toFixed(1)}) ${(ratio * 100).toFixed(0)}% above L20 (${l20.toFixed(1)})`,
};
}
// 5. Juice degradation — vig got worse while the line stayed flat.
async function signalJuiceDegradation(input) {
const { gameId, playerName, statType } = input;
if (!gameId || !playerName || !statType) return inactive('missing inputs');
const r = await juiceDegradation(gameId, playerName, statType);
if (!r) return inactive('not enough snapshots');
if (!r.applicable) return inactive('line moved too much for juice signal');
return { score: r.score, active: true, explanation: `juice worsening on ${r.worstSide}` };
}
// 6. Teammate return trap — key teammate returning → suppression.
function signalTeammateReturnTrap(input) {
const { gameContext = {} } = input;
const returning = gameContext.returning_teammate_usage_rate;
if (!Number.isFinite(returning) || returning <= 0) return inactive('no returning teammate');
return {
score: Math.min(1.0, returning * 0.5),
active: true,
explanation: `teammate returning with ${(returning * 100).toFixed(0)}% usage`,
};
}
// 7. Line consensus divergence — one book's line differs from the consensus.
function signalLineConsensusDivergence(input) {
const { odds = {} } = input;
const consensus = odds.consensus;
const playerLine = Number(odds.playerLine);
if (!consensus || !Number.isFinite(playerLine)) return inactive('no consensus or player line');
const median = Number(consensus.median);
if (!Number.isFinite(median)) return inactive('consensus median missing');
// Standard deviation across books; fall back to a 0.5 floor so even a
// tight consensus produces a meaningful divisor.
const stddev = Math.max(Number(consensus.stddev) || 0.5, 0.5);
const score = Math.min(1.0, Math.abs(playerLine - median) / stddev);
return {
score,
active: true,
explanation: `player line ${playerLine} vs consensus median ${median} (σ=${stddev})`,
};
}
const SIGNALS = [
['reverse_line_movement', signalReverseLineMovement],
['historical_hit_rate_paradox', signalHistoricalHitRateParadox],
['new_context_trap', signalNewContextTrap],
['recency_inflation', signalRecencyInflation],
['juice_degradation', signalJuiceDegradation],
['teammate_return_trap', signalTeammateReturnTrap],
['line_consensus_divergence', signalLineConsensusDivergence],
];
function recommend(composite) {
if (composite >= 0.5) return 'avoid';
if (composite >= 0.25) return 'caution';
return 'proceed';
}
async function getTrapScore(input = {}) {
const signals = {};
for (const [name, fn] of SIGNALS) {
try {
const result = await fn(input);
signals[name] = result;
} catch (err) {
signals[name] = { score: 0, active: false, explanation: `error: ${err?.message || 'unknown'}` };
}
}
const activeScores = Object.values(signals)
.filter((s) => s.active)
.map((s) => s.score);
const composite = activeScores.length === 0
? 0
: activeScores.reduce((a, b) => a + b, 0) / activeScores.length;
return {
composite,
signals,
active_count: activeScores.length,
recommendation: recommend(composite),
};
}
module.exports = {
getTrapScore,
normalizeName,
detectReturningTeammate,
__internals: {
signalReverseLineMovement,
signalHistoricalHitRateParadox,
signalNewContextTrap,
signalRecencyInflation,
signalJuiceDegradation,
signalTeammateReturnTrap,
signalLineConsensusDivergence,
recommend,
},
};
+201
View File
@@ -0,0 +1,201 @@
/**
* Engine 1 weight adjustment — the learning loop.
*
* Every resolved prop nudges Engine 1's factor weights in the direction
* the outcome implies. Factors that contributed to a winning grade get a
* small boost; factors behind a losing grade get pulled down. Each nudge
* is tiny on purpose:
* - max ±0.5% per resolution
* - weights clamped to [0.1, 5.0]
* - versioned per (sport, stat_type, factor_name) for rollback
* - skipped entirely until 20+ resolutions exist for the sport
* (don't overfit a small sample)
*
* Every adjustment writes a new row in engine1_weights — the table is
* append-only. To recall a factor's current weight, we read the latest
* version. Rolling back means inserting a new row whose weight equals
* an older version's weight.
*/
const { getSupabaseServiceClient } = require('../../utils/supabase');
const LEARNING_RATE = 0.005;
const MIN_WEIGHT = 0.1;
const MAX_WEIGHT = 5.0;
const MIN_RESOLUTIONS_TO_LEARN = 20;
const DEFAULT_WEIGHT = 1.0;
const GRADE_CONFIDENCE = {
'A+': 1.00, 'A': 0.90, 'A-': 0.80,
'B+': 0.65, 'B': 0.55, 'B-': 0.45,
'C+': 0.35, 'C': 0.25, 'C-': 0.20,
'D': 0.15, 'F': 0.10,
};
function clamp(w) {
return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, w));
}
async function countResolutions(sport) {
const supabase = getSupabaseServiceClient();
const { count, error } = await supabase
.from('resolution_results')
.select('id', { head: true, count: 'exact' })
.eq('sport', sport);
if (error) {
console.warn('[weightAdjuster] count failed:', error.message);
return 0;
}
return Number(count) || 0;
}
async function getCurrentWeights(sport, statType) {
// Latest version of each factor for (sport, stat_type).
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('engine1_weights')
.select('factor_name, weight, version')
.eq('sport', sport)
.eq('stat_type', statType)
.order('version', { ascending: false });
if (error) {
console.warn('[weightAdjuster] read failed:', error.message);
return {};
}
const latest = {};
for (const row of data || []) {
if (!(row.factor_name in latest)) {
latest[row.factor_name] = { weight: Number(row.weight), version: row.version };
}
}
// Flatten to { factor: weight }
const out = {};
for (const k of Object.keys(latest)) out[k] = latest[k].weight;
return out;
}
async function getNextVersion(sport, statType, factorName) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('engine1_weights')
.select('version')
.eq('sport', sport)
.eq('stat_type', statType)
.eq('factor_name', factorName)
.order('version', { ascending: false })
.limit(1);
if (error) {
console.warn('[weightAdjuster] version lookup failed:', error.message);
return 1;
}
const top = data?.[0]?.version;
return Number.isFinite(Number(top)) ? Number(top) + 1 : 1;
}
async function persistAdjustment(sport, statType, factorName, newWeight, prevWeight, reason, resolvedGradeId) {
const supabase = getSupabaseServiceClient();
const version = await getNextVersion(sport, statType, factorName);
const { error } = await supabase.from('engine1_weights').insert({
sport,
stat_type: statType,
factor_name: factorName,
weight: newWeight,
previous_weight: prevWeight,
adjustment_reason: reason,
resolved_grade_id: resolvedGradeId || null,
version,
});
if (error) {
console.warn('[weightAdjuster] insert failed:', error.message);
return null;
}
return version;
}
// Public entry point. resolvedGrade carries the Engine 1 grade, the prop's
// stat_type/sport, the resolved result, and the factors that drove the
// grade (top_factors or all_factors).
async function adjustWeights(resolvedGrade) {
const { sport, stat_type: statType, grade, result, factors, grade_id: resolvedGradeId } = resolvedGrade || {};
if (!sport || !statType || !grade || !result || !Array.isArray(factors) || factors.length === 0) {
return { skipped: true, reason: 'incomplete_input' };
}
if (result !== 'hit' && result !== 'miss') {
return { skipped: true, reason: 'non_decisive_result' };
}
const sampleCount = await countResolutions(sport);
if (sampleCount < MIN_RESOLUTIONS_TO_LEARN) {
return { skipped: true, reason: 'thin_sample', sampleCount };
}
const current = await getCurrentWeights(sport, statType);
const confidence = GRADE_CONFIDENCE[grade] ?? 0.5;
const sign = result === 'hit' ? 1 : -1;
const multiplier = 1 + sign * LEARNING_RATE * confidence;
const adjustments = [];
for (const factor of factors) {
const prev = current[factor] ?? DEFAULT_WEIGHT;
const next = clamp(prev * multiplier);
const version = await persistAdjustment(
sport, statType, factor, next, prev,
`${result} on grade ${grade}`,
resolvedGradeId,
);
adjustments.push({ factor, previous: prev, next, version });
}
return { skipped: false, adjustments, multiplier, confidence };
}
// Restore to a prior version by inserting a NEW row whose weight equals the
// target version's weight. Append-only is the safe primitive — we never
// mutate or delete history.
async function rollbackToVersion(sport, statType, factorName, targetVersion) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('engine1_weights')
.select('weight')
.eq('sport', sport)
.eq('stat_type', statType)
.eq('factor_name', factorName)
.eq('version', targetVersion)
.maybeSingle();
if (error || !data) {
console.warn('[weightAdjuster] rollback target not found');
return false;
}
const current = (await getCurrentWeights(sport, statType))[factorName] ?? DEFAULT_WEIGHT;
const version = await persistAdjustment(
sport, statType, factorName, Number(data.weight), current,
`rollback to v${targetVersion}`,
null,
);
return Number.isFinite(version);
}
async function getWeightHistory(sport, statType, factorName, limit = 50) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('engine1_weights')
.select('weight, previous_weight, adjustment_reason, version, created_at')
.eq('sport', sport)
.eq('stat_type', statType)
.eq('factor_name', factorName)
.order('version', { ascending: false })
.limit(limit);
if (error) return [];
return data || [];
}
module.exports = {
adjustWeights,
getCurrentWeights,
rollbackToVersion,
getWeightHistory,
LEARNING_RATE,
MIN_WEIGHT,
MAX_WEIGHT,
MIN_RESOLUTIONS_TO_LEARN,
__internals: { clamp, countResolutions, getNextVersion, persistAdjustment, GRADE_CONFIDENCE },
};
+127
View File
@@ -0,0 +1,127 @@
const SHARP_BOOKS = ['pinnacle', 'circa', 'bookmaker'];
const SQUARE_BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365'];
/**
* Detect discrepancy between sharp and square book consensus lines.
* @param {Array<{book: string, line: number}>} propLines
* @returns {object} { discrepancy, gap, sharp_consensus, square_consensus }
*/
function detectDiscrepancy(propLines) {
if (!propLines || propLines.length === 0) {
return { discrepancy: false, gap: 0, sharp_consensus: null, square_consensus: null };
}
const sharpLines = propLines.filter(p => SHARP_BOOKS.includes(p.book.toLowerCase()));
const squareLines = propLines.filter(p => SQUARE_BOOKS.includes(p.book.toLowerCase()));
if (sharpLines.length === 0 || squareLines.length === 0) {
return { discrepancy: false, gap: 0, sharp_consensus: null, square_consensus: null };
}
const sharpConsensus = sharpLines.reduce((s, p) => s + p.line, 0) / sharpLines.length;
const squareConsensus = squareLines.reduce((s, p) => s + p.line, 0) / squareLines.length;
const gap = Math.abs(sharpConsensus - squareConsensus);
return {
discrepancy: gap > 0.5,
gap: Math.round(gap * 100) / 100,
sharp_consensus: Math.round(sharpConsensus * 100) / 100,
square_consensus: Math.round(squareConsensus * 100) / 100,
sharp_books_used: sharpLines.length,
square_books_used: squareLines.length,
};
}
/**
* Detect steam move: 0.5+ movement at 3+ books within 10 minutes.
* @param {Array<{book: string, line: number, timestamp: string}>} movements
* @returns {object} { steam_move, books_moved, magnitude, window_minutes }
*/
function detectSteamMove(movements) {
if (!movements || movements.length < 3) {
return { steam_move: false, books_moved: 0, magnitude: 0, window_minutes: 0 };
}
const sorted = [...movements].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
const windowMs = 10 * 60 * 1000; // 10 minutes
for (let i = 0; i < sorted.length; i++) {
const windowStart = new Date(sorted[i].timestamp).getTime();
const windowEnd = windowStart + windowMs;
const inWindow = sorted.filter(m => {
const t = new Date(m.timestamp).getTime();
return t >= windowStart && t <= windowEnd;
});
// Group by book, find those with 0.5+ movement
const bookMovements = {};
for (const m of inWindow) {
if (!bookMovements[m.book]) bookMovements[m.book] = [];
bookMovements[m.book].push(m.line);
}
const significantMoves = Object.entries(bookMovements).filter(([_, lines]) => {
if (lines.length < 2) return false;
const range = Math.max(...lines) - Math.min(...lines);
return range >= 0.5;
});
// Also count books that appear with already-moved lines (single entry with magnitude info)
const booksWithMovement = inWindow.filter(m => Math.abs(m.line) >= 0.5);
const uniqueBooks = new Set(booksWithMovement.map(m => m.book));
if (uniqueBooks.size >= 3 || significantMoves.length >= 3) {
const allMagnitudes = booksWithMovement.map(m => Math.abs(m.line));
return {
steam_move: true,
books_moved: Math.max(uniqueBooks.size, significantMoves.length),
magnitude: Math.round((allMagnitudes.reduce((s, v) => s + v, 0) / allMagnitudes.length) * 100) / 100,
window_minutes: 10,
};
}
}
return { steam_move: false, books_moved: 0, magnitude: 0, window_minutes: 0 };
}
/**
* Get reliability score for a prop type in a sport from historical accuracy.
* @param {string} propType
* @param {string} sport
* @returns {number} Reliability score 0-1
*/
function getReliabilityScore(propType, sport) {
const reliabilityMap = {
nba: {
points: 0.72,
rebounds: 0.65,
assists: 0.68,
threes: 0.60,
steals: 0.45,
blocks: 0.42,
pts_rebs_asts: 0.70,
},
mlb: {
hits: 0.55,
home_runs: 0.40,
rbis: 0.48,
stolen_bases: 0.52,
strikeouts_pitcher: 0.65,
earned_runs: 0.58,
total_bases: 0.53,
},
};
const sportMap = reliabilityMap[sport.toLowerCase()];
if (!sportMap) return 0.50; // default
return sportMap[propType.toLowerCase()] || 0.50;
}
module.exports = {
SHARP_BOOKS,
SQUARE_BOOKS,
detectDiscrepancy,
detectSteamMove,
getReliabilityScore,
};
+76
View File
@@ -0,0 +1,76 @@
const HITTING_STATS = [
'hits', 'total_bases', 'home_runs', 'rbis', 'runs_scored',
'strikeouts_batter', 'walks', 'stolen_bases',
];
const PITCHING_STATS = [
'strikeouts', 'earned_runs', 'outs_recorded', 'walks_allowed',
'hits_allowed', 'pitches_thrown',
];
const ALL_MLB_STATS = [...HITTING_STATS, ...PITCHING_STATS];
function isMlbStatType(statType) {
return ALL_MLB_STATS.includes(statType);
}
function calculateMlbEdge(playerAvg, line, direction) {
if (playerAvg == null || line == null) return 0;
if (direction === 'over') {
return ((playerAvg - line) / line) * 100;
}
// under
return ((line - playerAvg) / line) * 100;
}
function gradeMlbProp({ player, stat_type, line, direction, seasonAvg, recentAvg, killConditions = [] }) {
if (!isMlbStatType(stat_type)) {
return { grade: 'D', confidence: 30, edge_pct: 0, composite: 0 };
}
const seasonEdge = calculateMlbEdge(seasonAvg, line, direction);
const recentEdge = calculateMlbEdge(recentAvg, line, direction);
// Weighted composite: 60% season, 40% recent
const edge_pct = Math.round((seasonEdge * 0.6 + recentEdge * 0.4) * 100) / 100;
// Grade thresholds based on edge
let grade;
if (edge_pct >= 5) {
grade = 'A';
} else if (edge_pct >= 3) {
grade = 'B';
} else if (edge_pct >= 1) {
grade = 'C';
} else {
grade = 'D';
}
// Confidence based on edge magnitude
let confidence;
if (grade === 'A') {
confidence = Math.min(95, 80 + Math.floor(edge_pct));
} else if (grade === 'B') {
confidence = Math.min(79, 65 + Math.floor(edge_pct));
} else if (grade === 'C') {
confidence = Math.min(64, 50 + Math.floor(edge_pct * 2));
} else {
confidence = Math.max(30, 45 + Math.floor(edge_pct));
}
// Kill condition penalty: cap at C and reduce confidence by 15 per condition
if (killConditions.length > 0) {
if (grade === 'A' || grade === 'B') {
grade = 'C';
}
confidence -= killConditions.length * 15;
}
confidence = Math.max(30, Math.min(95, confidence));
const composite = Math.round(edge_pct * 100) / 100;
return { grade, confidence, edge_pct, composite };
}
module.exports = { gradeMlbProp, calculateMlbEdge, isMlbStatType, HITTING_STATS, PITCHING_STATS, ALL_MLB_STATS };
+174
View File
@@ -0,0 +1,174 @@
const axios = require('axios');
const WEATHER_GOV_BASE = 'https://api.weather.gov';
const OPEN_METEO_BASE = 'https://api.open-meteo.com/v1/forecast';
function classifyLineMove(movement, hoursFromOpen) {
if (Math.abs(movement) < 0.5) return null;
if (hoursFromOpen < 2) return 'sharp';
return 'public';
}
async function checkWeather(parkCoords, timeout = 3000) {
const [lat, lon] = parkCoords;
// Try api.weather.gov first
try {
const pointRes = await axios.get(
`${WEATHER_GOV_BASE}/points/${lat},${lon}`,
{ timeout, headers: { 'User-Agent': 'VYNDR/1.0' } }
);
const forecastUrl = pointRes.data.properties.forecastHourly;
const forecastRes = await axios.get(forecastUrl, {
timeout,
headers: { 'User-Agent': 'VYNDR/1.0' },
});
const period = forecastRes.data.properties.periods[0];
return {
wind_speed: parseInt(period.windSpeed) || 0,
wind_direction: period.windDirection || 'N',
temp: period.temperature || 72,
humidity: period.relativeHumidity ? period.relativeHumidity.value : 50,
rain_probability: period.probabilityOfPrecipitation ? period.probabilityOfPrecipitation.value : 0,
};
} catch (_err) {
// Fallback to open-meteo
try {
const res = await axios.get(OPEN_METEO_BASE, {
params: {
latitude: lat,
longitude: lon,
hourly: 'temperature_2m,relative_humidity_2m,wind_speed_10m,wind_direction_10m,precipitation_probability',
forecast_days: 1,
temperature_unit: 'fahrenheit',
wind_speed_unit: 'mph',
},
timeout: 5000,
});
const hourly = res.data.hourly;
const idx = new Date().getHours();
return {
wind_speed: hourly.wind_speed_10m[idx] || 0,
wind_direction: degreesToCardinal(hourly.wind_direction_10m[idx] || 0),
temp: hourly.temperature_2m[idx] || 72,
humidity: hourly.relative_humidity_2m[idx] || 50,
rain_probability: hourly.precipitation_probability[idx] || 0,
};
} catch (_fallbackErr) {
return {
wind_speed: 0,
wind_direction: 'N',
temp: 72,
humidity: 50,
rain_probability: 0,
};
}
}
}
function degreesToCardinal(deg) {
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
return dirs[Math.round(deg / 45) % 8];
}
function evaluateMlbKillConditions(context) {
const {
inLineup,
pitcherScratched,
weather,
platoonDelta,
paVsHandedness,
lineMovement,
hoursFromOpen,
parkFactor,
rainProbability,
onInjuryReport,
} = context;
const conditions = [];
// 1. LINEUP_OUT
if (inLineup === false) {
conditions.push({
code: 'LINEUP_OUT',
reason: 'Player not in confirmed lineup',
});
}
// 2. PITCHER_SCRATCH
if (pitcherScratched === true) {
conditions.push({
code: 'PITCHER_SCRATCH',
reason: 'Starting pitcher has been scratched',
});
}
// 3. WIND_IN
if (weather && weather.wind_speed >= 15 && weather.wind_direction === 'IN') {
conditions.push({
code: 'WIND_IN',
reason: `Wind blowing in at ${weather.wind_speed} mph — suppresses power`,
});
}
// 4. PLATOON_DISADVANTAGE
if (platoonDelta != null && platoonDelta > 12) {
conditions.push({
code: 'PLATOON_DISADVANTAGE',
reason: `Platoon split delta of ${platoonDelta}% exceeds 12% threshold`,
});
}
// 5. SMALL_SAMPLE
if (paVsHandedness != null && paVsHandedness < 50) {
conditions.push({
code: 'SMALL_SAMPLE',
reason: `Only ${paVsHandedness} PA vs current handedness`,
});
}
// 6. LINE_MOVE_AGAINST
if (lineMovement != null && Math.abs(lineMovement) >= 0.5) {
const moveType = classifyLineMove(lineMovement, hoursFromOpen || 0);
conditions.push({
code: 'LINE_MOVE_AGAINST',
reason: `Line moved ${lineMovement > 0 ? '+' : ''}${lineMovement} (${moveType} money)`,
});
}
// 7. PARK_SUPPRESSOR
if (parkFactor != null && parkFactor < 0.90) {
conditions.push({
code: 'PARK_SUPPRESSOR',
reason: `Park factor ${parkFactor} below 0.90 threshold`,
});
}
// 8. WEATHER_RAIN
if (rainProbability != null && rainProbability > 50) {
conditions.push({
code: 'WEATHER_RAIN',
reason: `Rain probability at ${rainProbability}%`,
});
}
// 9. INJURY_REPORT
if (onInjuryReport === true) {
conditions.push({
code: 'INJURY_REPORT',
reason: 'Player appears on injury report',
});
}
// 10. HUMIDITY_SUPPRESSOR
if (weather && weather.humidity > 80 && weather.temp < 60) {
conditions.push({
code: 'HUMIDITY_SUPPRESSOR',
reason: `Humidity ${weather.humidity}% with temp ${weather.temp}F — suppresses ball flight`,
});
}
return conditions;
}
module.exports = { evaluateMlbKillConditions, classifyLineMove, checkWeather };
+64
View File
@@ -0,0 +1,64 @@
const axios = require('axios');
const MLB_API_BASE = 'https://statsapi.mlb.com/api/v1';
const TIMEOUT = 10000;
async function getPlayerStats(playerId) {
const { data } = await axios.get(`${MLB_API_BASE}/people/${playerId}/stats`, {
params: {
stats: 'season',
group: 'hitting,pitching',
season: new Date().getFullYear(),
},
timeout: TIMEOUT,
});
return data;
}
async function getGameLog(playerId, season) {
const yr = season || new Date().getFullYear();
const { data } = await axios.get(`${MLB_API_BASE}/people/${playerId}/stats`, {
params: {
stats: 'gameLog',
group: 'hitting,pitching',
season: yr,
},
timeout: TIMEOUT,
});
return data;
}
async function searchPlayer(name) {
const { data } = await axios.get(`${MLB_API_BASE}/sports/1/players`, {
params: {
search: name,
season: new Date().getFullYear(),
},
timeout: TIMEOUT,
});
return data;
}
async function getTeamRoster(teamId) {
const { data } = await axios.get(`${MLB_API_BASE}/teams/${teamId}/roster`, {
params: {
rosterType: 'active',
},
timeout: TIMEOUT,
});
return data;
}
async function getTodaysGames() {
const today = new Date().toISOString().slice(0, 10);
const { data } = await axios.get(`${MLB_API_BASE}/schedule`, {
params: {
sportId: 1,
date: today,
},
timeout: TIMEOUT,
});
return data;
}
module.exports = { getPlayerStats, getGameLog, searchPlayer, getTeamRoster, getTodaysGames };
+105
View File
@@ -0,0 +1,105 @@
/**
* Walk-forward validation: time-stratified only, no look-ahead bias.
* @param {Array<{predicted: number, timestamp: string}>} predictions
* @param {Array<{actual: number, timestamp: string}>} actuals
* @returns {object} Accuracy metrics
*/
function walkForwardValidate(predictions, actuals) {
if (!predictions || !actuals || predictions.length === 0 || actuals.length === 0) {
return { accuracy: 0, mae: 0, rmse: 0, n: 0, hit_rate: 0 };
}
const paired = predictions.map((pred, i) => {
const actual = actuals[i];
if (!actual) return null;
return { predicted: pred.predicted, actual: actual.actual, timestamp: pred.timestamp };
}).filter(Boolean);
// Sort by timestamp to enforce time-stratification
paired.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
const n = paired.length;
if (n === 0) return { accuracy: 0, mae: 0, rmse: 0, n: 0, hit_rate: 0 };
let totalError = 0;
let totalSquaredError = 0;
let hits = 0;
for (const p of paired) {
const error = Math.abs(p.predicted - p.actual);
totalError += error;
totalSquaredError += error * error;
// Hit = within 10% of actual or within 1 unit
if (error <= Math.max(Math.abs(p.actual) * 0.1, 1)) hits++;
}
return {
accuracy: Math.round((hits / n) * 1000) / 1000,
mae: Math.round((totalError / n) * 100) / 100,
rmse: Math.round(Math.sqrt(totalSquaredError / n) * 100) / 100,
n,
hit_rate: Math.round((hits / n) * 1000) / 1000,
};
}
/**
* Calculate Closing Line Value at multiple checkpoints.
* @param {number} predictionLine - Our predicted line at time of prediction
* @param {number} lineAt24h - Market line 24 hours before tip
* @param {number} lineAtTip - Market line at tip-off
* @returns {object} { clv_at_prediction, clv_at_24hr, clv_at_tip }
*/
function calculateCLV(predictionLine, lineAt24h, lineAtTip) {
return {
clv_at_prediction: Math.round((lineAtTip - predictionLine) * 100) / 100,
clv_at_24hr: Math.round((lineAtTip - lineAt24h) * 100) / 100,
clv_at_tip: 0, // By definition, CLV at tip is 0 (reference point)
};
}
/**
* Check for model drift: 10 consecutive CLV below 0 triggers alert.
* @param {Array<number>} clvHistory - Array of CLV values, most recent last
* @returns {object} { drift_detected, consecutive_negative, alert }
*/
function checkDrift(clvHistory) {
if (!clvHistory || clvHistory.length === 0) {
return { drift_detected: false, consecutive_negative: 0, alert: false };
}
let consecutiveNeg = 0;
// Count from the end
for (let i = clvHistory.length - 1; i >= 0; i--) {
if (clvHistory[i] < 0) {
consecutiveNeg++;
} else {
break;
}
}
return {
drift_detected: consecutiveNeg >= 10,
consecutive_negative: consecutiveNeg,
alert: consecutiveNeg >= 10,
};
}
/**
* Cap weight changes to prevent overfitting.
* @param {number} currentWeight
* @param {number} proposedWeight
* @param {number} maxDelta - Maximum allowed change per cycle (default 0.05)
* @returns {number} Capped weight
*/
function applyLearningRateCap(currentWeight, proposedWeight, maxDelta = 0.05) {
const delta = proposedWeight - currentWeight;
const clampedDelta = Math.max(-maxDelta, Math.min(maxDelta, delta));
return Math.round((currentWeight + clampedDelta) * 10000) / 10000;
}
module.exports = {
walkForwardValidate,
calculateCLV,
checkDrift,
applyLearningRateCap,
};
+4 -4
View File
@@ -6,7 +6,7 @@ const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports';
const CACHE_TTL = 900; // 15 minutes in seconds
const SPORT_KEYS = { nba: 'basketball_nba', ncaab: 'basketball_ncaab' };
const ALL_MARKETS = Object.keys(MARKET_MAP).join(',') + ',spreads';
const BOOKMAKERS = 'draftkings,fanduel,betmgm';
const BOOKMAKERS = 'draftkings,fanduel,betmgm,caesars,fanatics,bet365,hardrockbet,pointsbet,betrivers';
function getCacheKey(sport) {
const now = new Date();
@@ -33,7 +33,7 @@ async function updateQuota(redis, headers) {
await redis.hset(key, 'remaining', String(remaining), 'used', String(used || 0), 'last_checked', new Date().toISOString());
await redis.expire(key, 60 * 60 * 24 * 35); // keep for ~1 month
if (parseInt(remaining, 10) < 50) {
console.warn(`[BetonBLK] Odds API quota low: ${remaining} credits remaining`);
console.warn(`[VYNDR] Odds API quota low: ${remaining} credits remaining`);
}
}
return remaining != null ? parseInt(remaining, 10) : null;
@@ -81,7 +81,7 @@ async function fetchAllOdds(sport, apiKey) {
for (const event of events) {
const quotaLeft = lastHeaders['x-requests-remaining'];
if (quotaLeft != null && parseInt(quotaLeft, 10) <= 0) {
console.warn('[BetonBLK] Quota exhausted mid-fetch, stopping');
console.warn('[VYNDR] Quota exhausted mid-fetch, stopping');
break;
}
@@ -157,7 +157,7 @@ async function getOdds(sport) {
scratchedPlayers = cascadeResult.scratchedPlayers || [];
} catch (e) {
// Non-fatal — log and continue
console.warn('[BetonBLK] Movement/cascade detection error:', e.message);
console.warn('[VYNDR] Movement/cascade detection error:', e.message);
}
return {
+48
View File
@@ -0,0 +1,48 @@
/**
* CLV (Closing Line Value) tracker.
*
* For each resolved grade, compare the line at which we graded (open) to
* the line at game start (close). Positive CLV means the line moved
* toward us — a leading indicator of long-term profitability that's
* independent of whether the prop actually hit.
*/
const { americanToImplied } = require('./LineShoppingEngine');
/**
* @param {{graded_line:number, graded_odds:number, close_line:number, close_odds:number, direction:'over'|'under'}} entry
*/
function clvFor(entry) {
if (!entry) return null;
const dir = entry.direction;
const gI = americanToImplied(entry.graded_odds);
const cI = americanToImplied(entry.close_odds);
if (gI == null || cI == null) return null;
// Over: line went DOWN = good for us (book thinks fewer); odds went up
// (less juice). We compute edge as (graded_implied - close_implied) for
// Over and the negation for Under so a positive value always means CLV+.
const oddsClv = dir === 'over' ? gI - cI : cI - gI;
const lineDelta = entry.close_line - entry.graded_line;
const lineClv = dir === 'over' ? -lineDelta : lineDelta;
return {
odds_clv: oddsClv,
line_clv: lineClv,
positive: oddsClv > 0 || lineClv > 0,
};
}
function summarize(entries) {
const items = (entries || []).map((e) => ({ ...e, clv: clvFor(e) })).filter((e) => e.clv);
if (!items.length) return { count: 0, positive_rate: null, avg_odds_clv: null, avg_line_clv: null };
const positive = items.filter((i) => i.clv.positive).length;
const avgOdds = items.reduce((s, i) => s + i.clv.odds_clv, 0) / items.length;
const avgLine = items.reduce((s, i) => s + i.clv.line_clv, 0) / items.length;
return {
count: items.length,
positive_rate: positive / items.length,
avg_odds_clv: avgOdds,
avg_line_clv: avgLine,
};
}
module.exports = { clvFor, summarize };
+42
View File
@@ -0,0 +1,42 @@
/**
* Cascade engine.
*
* Input: an injury / lineup / weather delta + the set of props it touches.
* Output: a cascade alert with before/after grade per affected prop.
*
* The actual regrade happens in the grading engine; we just compose the
* notification payload. Persist to `cascade_alerts` and surface in the
* dead-hours feed + notification bell.
*/
function buildAlert({ trigger, before = [], after = [] } = {}) {
if (!trigger || typeof trigger !== 'object') {
throw new Error('cascade: trigger required');
}
const beforeByKey = new Map((before || []).map((p) => [p.key, p]));
const affected = [];
for (const a of after || []) {
const b = beforeByKey.get(a.key);
if (!b) continue;
if (a.grade === b.grade) continue;
affected.push({
key: a.key,
player: a.player ?? b.player,
stat: a.stat ?? b.stat,
old_grade: b.grade,
new_grade: a.grade,
old_projection: b.projection ?? null,
new_projection: a.projection ?? null,
direction: a.direction ?? b.direction,
});
}
return {
trigger_type: trigger.type, // 'injury' | 'lineup' | 'weather' | 'ref' | 'umpire'
trigger_detail: trigger.detail || trigger,
affected_props: affected,
affected_count: affected.length,
created_at: new Date().toISOString(),
};
}
module.exports = { buildAlert };
@@ -0,0 +1,38 @@
/**
* Correlation engine.
*
* Pearson correlation between two stat streams. Caller feeds in pairs of
* arrays (same player or same team) and we return the coefficient plus
* the implied SGP adjustment for value flagging.
*/
function pearson(xs, ys) {
if (!Array.isArray(xs) || !Array.isArray(ys) || xs.length !== ys.length || xs.length < 3) return null;
let sx = 0, sy = 0;
for (let i = 0; i < xs.length; i++) { sx += xs[i]; sy += ys[i]; }
const mx = sx / xs.length, my = sy / ys.length;
let num = 0, dx = 0, dy = 0;
for (let i = 0; i < xs.length; i++) {
const a = xs[i] - mx;
const b = ys[i] - my;
num += a * b; dx += a * a; dy += b * b;
}
const den = Math.sqrt(dx * dy);
if (den === 0) return 0;
return num / den;
}
/**
* Compare measured correlation to the book's implicit SGP adjustment.
* `bookAdjustment` is the multiplier the book applies to the joint price
* vs the independent-events price. >1 means the book over-prices the
* correlation; <1 means under-priced (VALUE).
*/
function flagValue(measuredR, bookAdjustment) {
if (measuredR == null || bookAdjustment == null) return null;
if (bookAdjustment < 1 && measuredR > 0.15) return 'VALUE';
if (bookAdjustment > 1.2 && measuredR < 0.1) return 'OVERPRICED';
return null;
}
module.exports = { pearson, flagValue };
+51
View File
@@ -0,0 +1,51 @@
/**
* Expected Value calculator.
*
* Inputs: book odds + VYNDR's modeled probability (derived from grade tier).
* Output: edge % and a friendly "+EV: 8.2%" string for the grade card.
*/
const { americanToImplied } = require('./LineShoppingEngine');
// Calibrated probabilities per grade tier — these track the published Ledger.
// Refresh from the grade_history table on a schedule.
const GRADE_PROBABILITY = Object.freeze({
'A+': 0.74,
'A': 0.65,
'A-': 0.62,
'B+': 0.58,
'B': 0.55,
'B-': 0.53,
'C+': 0.50,
'C': 0.48,
'C-': 0.46,
'D': 0.40,
'F': 0.35,
});
function probabilityForGrade(grade) {
if (!grade) return null;
return GRADE_PROBABILITY[grade] ?? GRADE_PROBABILITY[grade[0]] ?? null;
}
/**
* @param {{grade:string, odds:number}} input
* @returns {{ev_pct:number, edge_pct:number, label:string}|null}
*/
function calculate({ grade, odds } = {}) {
const p = probabilityForGrade(grade);
const implied = americanToImplied(odds);
if (p == null || implied == null) return null;
const edge = p - implied;
const edgePct = edge / implied;
const sign = edge >= 0 ? '+' : '';
return {
modeled_probability: p,
implied_probability: implied,
edge,
edge_pct: edgePct,
label: `${sign}EV: ${(Math.abs(edgePct) * 100).toFixed(1)}%`,
};
}
module.exports = { calculate, probabilityForGrade, GRADE_PROBABILITY };
@@ -0,0 +1,85 @@
/**
* Line shopping — for each unique prop (game/player/stat), find the best
* line per side across books.
*
* Best Over = lowest line + best odds at that line.
* Best Under = highest line + best odds at that line.
*
* We also flag "outlier" books — a book that's 1+ points off the median.
*/
function propKey(p) {
return `${p.game_id}|${p.player_id ?? p.player_name}|${p.stat_type}`;
}
function americanToImplied(odds) {
if (typeof odds !== 'number' || !Number.isFinite(odds)) return null;
return odds > 0 ? 100 / (odds + 100) : -odds / (-odds + 100);
}
function median(values) {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
function process(props) {
const grouped = new Map();
for (const p of props || []) {
const key = propKey(p);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(p);
}
const out = [];
for (const [key, rows] of grouped.entries()) {
if (rows.length === 1) {
out.push({ ...rows[0], best_over: rows[0], best_under: rows[0], line_outliers: [] });
continue;
}
const lines = rows.map((r) => r.line).filter((n) => typeof n === 'number');
const med = median(lines);
const overs = rows.filter((r) => r.odds_over != null);
const unders = rows.filter((r) => r.odds_under != null);
// Best Over = lowest line, then best (highest implied prob) odds at that line.
let bestOver = null;
for (const r of overs) {
if (!bestOver) { bestOver = r; continue; }
if (r.line < bestOver.line) bestOver = r;
else if (r.line === bestOver.line) {
const a = americanToImplied(r.odds_over);
const b = americanToImplied(bestOver.odds_over);
if (a != null && b != null && a < b) bestOver = r;
}
}
let bestUnder = null;
for (const r of unders) {
if (!bestUnder) { bestUnder = r; continue; }
if (r.line > bestUnder.line) bestUnder = r;
else if (r.line === bestUnder.line) {
const a = americanToImplied(r.odds_under);
const b = americanToImplied(bestUnder.odds_under);
if (a != null && b != null && a < b) bestUnder = r;
}
}
const outliers = (med != null)
? rows.filter((r) => Math.abs(r.line - med) >= 1).map((r) => ({ book: r.book, line: r.line, delta: r.line - med }))
: [];
out.push({
key,
median_line: med,
books: rows,
best_over: bestOver,
best_under: bestUnder,
line_outliers: outliers,
});
}
return out;
}
module.exports = { process, americanToImplied };
@@ -0,0 +1,54 @@
/**
* Middle detection across books.
*
* A middle exists when one book has Over X.5 and another has Under Y.5 with
* X < Y — any actual result in [X+1, Y-1] wins both sides. We only flag
* middles where VYNDR's projection puts the probability of landing in the
* middle above 15%.
*/
const { americanToImplied } = require('./LineShoppingEngine');
function approxLandsBetween(projection, lo, hi, sigma = 5) {
if (projection == null) return null;
// Crude normal-ish band: pretend sigma is half the typical spread; a real
// model would use the per-stat empirical distribution from grade_history.
const cdf = (x) => 0.5 * (1 + Math.tanh((x - projection) / (sigma * 1.2533)));
return cdf(hi) - cdf(lo);
}
function detect(shoppedProps, { minProbability = 0.15 } = {}) {
const middles = [];
for (const group of shoppedProps || []) {
const rows = group.books || [];
for (let i = 0; i < rows.length; i++) {
for (let j = 0; j < rows.length; j++) {
if (i === j) continue;
const a = rows[i]; // candidate Over
const b = rows[j]; // candidate Under
if (typeof a.line !== 'number' || typeof b.line !== 'number') continue;
if (a.line >= b.line) continue;
if (a.odds_over == null || b.odds_under == null) continue;
const middleLo = a.line + 0.5;
const middleHi = b.line - 0.5;
if (middleHi < middleLo) continue;
const prob = approxLandsBetween(group.projection ?? group.vyndr_projection, middleLo, middleHi);
if (prob == null) continue;
if (prob < minProbability) continue;
middles.push({
key: group.key,
over: { book: a.book, line: a.line, odds: a.odds_over, implied: americanToImplied(a.odds_over) },
under: { book: b.book, line: b.line, odds: b.odds_under, implied: americanToImplied(b.odds_under) },
window: [middleLo, middleHi],
probability: prob,
});
}
}
}
return middles;
}
module.exports = { detect };
+57
View File
@@ -0,0 +1,57 @@
/**
* Steam detection — flags lines that move 1+ points in <2 hours.
*
* Inputs: a stream of { prop_key, book, line, odds, recorded_at } samples.
* The orchestrator persists samples to `line_history` and calls check() with
* the rolling window for tonight's slate.
*/
const TWO_HOURS_MS = 2 * 60 * 60_000;
const STEAM_THRESHOLD = 1;
/**
* @param {Array<{prop_key:string, book:string, line:number, odds:number|null, recorded_at:string|number}>} samples
* @returns {Array<{prop_key:string, book:string, from_line:number, to_line:number, delta:number, duration_ms:number, started_at:string, ended_at:string}>}
*/
function check(samples) {
if (!Array.isArray(samples) || samples.length === 0) return [];
// Group samples by prop_key + book and sort chronologically.
const buckets = new Map();
for (const s of samples) {
const k = `${s.prop_key}|${s.book}`;
if (!buckets.has(k)) buckets.set(k, []);
buckets.get(k).push({ ...s, t: new Date(s.recorded_at).getTime() });
}
const flags = [];
for (const [key, rows] of buckets.entries()) {
rows.sort((a, b) => a.t - b.t);
for (let i = 0; i < rows.length; i++) {
// Walk forward in time and stop as soon as the gap > window.
const start = rows[i];
for (let j = i + 1; j < rows.length; j++) {
const end = rows[j];
if (end.t - start.t > TWO_HOURS_MS) break;
const delta = end.line - start.line;
if (Math.abs(delta) >= STEAM_THRESHOLD) {
const [propKey, book] = key.split('|');
flags.push({
prop_key: propKey,
book,
from_line: start.line,
to_line: end.line,
delta,
duration_ms: end.t - start.t,
started_at: new Date(start.t).toISOString(),
ended_at: new Date(end.t).toISOString(),
});
break; // one flag per starting sample
}
}
}
}
return flags;
}
module.exports = { check, TWO_HOURS_MS, STEAM_THRESHOLD };
+446
View File
@@ -0,0 +1,446 @@
"""
VYNDR — Consolidated Python Service
Master Flask app. Registers all blueprints. Health check. Rate limiting.
Self-documenting API. Single process on port 5001.
"""
import os
import sys
import json
import logging
from datetime import datetime
from flask import Flask, jsonify
from flask_cors import CORS
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='[%(asctime)s] %(levelname)s %(name)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger('vyndr')
# Add utils to path for imports
sys.path.insert(0, os.path.dirname(__file__))
app = Flask(__name__)
# Request body size limit — 1MB default (OCR validates its own 10MB limit)
app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024
# CORS — locked to ALLOWED_ORIGINS (Vercel domain + localhost)
ALLOWED_ORIGINS = os.environ.get('ALLOWED_ORIGINS', 'http://localhost:3000').split(',')
CORS(app, resources={r'/api/*': {
'origins': ALLOWED_ORIGINS,
'methods': ['GET', 'POST', 'OPTIONS'],
'allow_headers': ['Authorization', 'Content-Type', 'X-API-Key'],
'max_age': 3600
}})
# Rate limiting — real IP from X-Forwarded-For (Railway proxy)
def _get_real_ip():
from flask import request as _req
forwarded = _req.headers.get('X-Forwarded-For', '')
if forwarded:
return forwarded.split(',')[0].strip()
return _req.remote_addr or '127.0.0.1'
limiter = Limiter(
app=app,
key_func=_get_real_ip,
default_limits=["60 per minute"],
storage_uri="memory://"
)
# Shadow mode — set to False after 2 weeks of verified accuracy
SHADOW_MODE = os.environ.get('SHADOW_MODE', 'true').lower() == 'true'
# --- Security: Headers, Logging, Error Handling ---
@app.after_request
def add_security_headers(response):
"""Add security headers to every response."""
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
response.headers['Content-Security-Policy'] = "default-src 'self'"
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers.pop('Server', None)
return response
@app.before_request
def before_request_security():
"""Log every request for security monitoring."""
try:
from utils.security_logger import log_request
from flask import request as _req
log_request(_req)
except Exception:
pass # Security logging must never block requests
@app.errorhandler(Exception)
def handle_exception(e):
"""Never expose internal errors in production."""
from werkzeug.exceptions import HTTPException
logger.error(f'[ERROR] Unhandled: {e}', exc_info=True)
if isinstance(e, HTTPException):
return jsonify({'error': e.description}), e.code
if os.environ.get('FLASK_ENV') == 'production':
return jsonify({'error': 'Internal server error'}), 500
return jsonify({'error': str(e)}), 500
@app.errorhandler(404)
def not_found(e):
return jsonify({'error': 'Endpoint not found'}), 404
@app.errorhandler(405)
def method_not_allowed(e):
return jsonify({'error': 'Method not allowed'}), 405
@app.errorhandler(413)
def payload_too_large(e):
return jsonify({'error': 'Request payload too large. Max 1MB (10MB for images).'}), 413
@app.errorhandler(429)
def rate_limited(e):
return jsonify({'error': 'Rate limit exceeded. Try again later.'}), 429
# --- Register Blueprints ---
from blueprints.evolution import evolution_bp
app.register_blueprint(evolution_bp, url_prefix='/api/evolution')
# Import remaining blueprints (registered as they are built in later phases)
try:
from blueprints.synergy import synergy_bp
app.register_blueprint(synergy_bp, url_prefix='/api/synergy')
except ImportError:
logger.info('[VYNDR] Synergy blueprint not yet available')
try:
from blueprints.mlb import mlb_bp
app.register_blueprint(mlb_bp, url_prefix='/api/mlb')
except ImportError:
logger.info('[VYNDR] MLB blueprint not yet available')
try:
from blueprints.nba_context import nba_context_bp
app.register_blueprint(nba_context_bp, url_prefix='/api/nba')
except ImportError:
logger.info('[VYNDR] NBA Context blueprint not yet available')
try:
from blueprints.lineup_intelligence import lineup_bp
app.register_blueprint(lineup_bp, url_prefix='/api/lineups')
except ImportError:
logger.info('[VYNDR] Lineup Intelligence blueprint not yet available')
try:
from blueprints.odds_scanner import odds_bp
app.register_blueprint(odds_bp, url_prefix='/api/odds')
except ImportError:
logger.info('[VYNDR] Odds Scanner blueprint not yet available')
try:
from blueprints.calibration import calibration_bp
app.register_blueprint(calibration_bp, url_prefix='/api/calibration')
except ImportError:
logger.info('[VYNDR] Calibration blueprint not yet available')
try:
from blueprints.resolution import resolution_bp
app.register_blueprint(resolution_bp, url_prefix='/api/resolution')
except ImportError:
logger.info('[VYNDR] Resolution blueprint not yet available')
try:
from blueprints.image_grade import image_grade_bp
app.register_blueprint(image_grade_bp, url_prefix='/api/grade')
except ImportError:
logger.info('[VYNDR] Image Grade blueprint not yet available')
# --- Supplement Blueprints ---
try:
from blueprints.coaching import coaching_bp
app.register_blueprint(coaching_bp, url_prefix='/api/coaching')
except ImportError:
logger.info('[VYNDR] Coaching blueprint not yet available')
try:
from blueprints.redistribution import redistribution_bp
app.register_blueprint(redistribution_bp, url_prefix='/api/redistribution')
except ImportError:
logger.info('[VYNDR] Redistribution blueprint not yet available')
try:
from blueprints.unconventional import unconventional_bp
app.register_blueprint(unconventional_bp, url_prefix='/api/unconventional')
except ImportError:
logger.info('[VYNDR] Unconventional blueprint not yet available')
# --- Health Check ---
@app.route('/health', methods=['GET'])
def health_check():
"""
Health check endpoint for deployment monitoring.
Checks connectivity to all dependent services.
Returns:
200 if all services healthy, 503 if any degraded.
"""
services = {}
# Supabase
try:
from utils.supabase_client import get_supabase_client
client = get_supabase_client()
services['supabase'] = 'ok' if client else 'not_configured'
except Exception:
services['supabase'] = 'error'
# Redis
try:
import redis
r = redis.from_url(os.environ.get('REDIS_URL', 'redis://127.0.0.1:6379'))
r.ping()
services['redis'] = 'ok'
except Exception:
services['redis'] = 'unavailable'
# Odds API
services['odds_api'] = 'configured' if os.environ.get('ODDS_API_KEY') else 'not_configured'
# nba_api
try:
import nba_api
services['nba_api'] = 'available'
except ImportError:
services['nba_api'] = 'not_installed'
# Weather API (Open-Meteo — always available, no key)
services['weather_api'] = 'ok'
# MLB Stats API
try:
import statsapi
services['mlb_stats_api'] = 'available'
except ImportError:
services['mlb_stats_api'] = 'not_installed'
all_healthy = all(s in ('ok', 'available', 'configured') for s in services.values())
return jsonify({
'status': 'ok' if all_healthy else 'degraded',
'version': '5.1',
'shadow_mode': SHADOW_MODE,
'services': services,
'timestamp': datetime.utcnow().isoformat()
}), 200 if all_healthy else 503
# --- Self-Documenting API ---
@app.route('/api/docs', methods=['GET'])
def api_docs():
"""
Self-documenting API reference for frontend integration.
Lists all available endpoints with method, path, and body schema.
"""
return jsonify({
'endpoints': {
'health': {'method': 'GET', 'path': '/health'},
'nba_grade': {
'method': 'POST', 'path': '/api/nba/grade',
'body': '{player_name, stat_type, line, over_under, user_id}'
},
'nba_sub_scores': {
'method': 'GET',
'path': '/api/nba/sub-scores/{player_id}/{game_id}'
},
'mlb_grade': {
'method': 'POST', 'path': '/api/mlb/grade',
'body': '{player_name, stat_type, line, over_under, pitcher_id?, user_id}'
},
'scan_slate': {'method': 'GET', 'path': '/api/odds/scan/{sport}'},
'resolve_grades': {
'method': 'POST',
'path': '/api/calibration/resolve/{game_date}'
},
'brier_score': {
'method': 'GET',
'path': '/api/calibration/brier-score/{sport}'
},
'clv_report': {
'method': 'GET',
'path': '/api/calibration/clv/{sport}'
},
'blind_spots': {
'method': 'GET',
'path': '/api/calibration/blind-spots/{sport}'
},
'grade_from_image': {
'method': 'POST', 'path': '/api/grade/from-image'
},
'parlay_grade': {
'method': 'POST', 'path': '/api/parlay/grade',
'body': '{legs: [...]}'
},
'synergy_team': {
'method': 'GET',
'path': '/api/synergy/team-playtypes/{team_id}'
},
'evolution_detect': {
'method': 'POST', 'path': '/api/evolution/detect-changepoints',
'body': '{values, min_size?, penalty?, player_id?, metric?}'
},
'api_docs': {'method': 'GET', 'path': '/api/docs'},
# Supplement endpoints
'coaching_tendencies': {
'method': 'GET',
'path': '/api/coaching/tendencies/{coach_id}?sport={sport}'
},
'coaching_shift': {
'method': 'GET',
'path': '/api/coaching/shift-detection/{team_id}?sport={sport}'
},
'redistribution': {
'method': 'GET',
'path': '/api/redistribution/calculate/{player_out_id}/{game_id}'
},
'alt_lines': {
'method': 'GET',
'path': '/api/odds/alt-lines/{sport}/{player_name}/{stat_type}'
},
'evolution_scan': {
'method': 'GET',
'path': '/api/evolution/scan/{sport}'
},
'unconventional_status': {
'method': 'GET',
'path': '/api/unconventional/status'
},
'unconventional_validate': {
'method': 'POST',
'path': '/api/unconventional/validate/{factor_name}'
}
},
'version': '5.1',
'shadow_mode': SHADOW_MODE
})
# --- Cold Start Boot Sequence ---
def cold_start_boot():
"""
Day-one initialization. Order matters — later steps depend on earlier ones.
Called once on startup. Non-fatal failures are logged but don't block boot.
"""
logger.info('[VYNDR] Cold start boot sequence initiated')
# Load static data files
data_dir = os.path.join(os.path.dirname(__file__), 'data')
_load_json(os.path.join(data_dir, 'park_factors.json'), 'park_factors')
_load_json(os.path.join(data_dir, 'reporter_database.json'), 'reporter_database')
_load_json(os.path.join(data_dir, 'timezone_map.json'), 'timezone_map')
_load_json(os.path.join(data_dir, 'grade_thresholds.json'), 'grade_thresholds')
# Seed reporter database into Supabase reporter_trust table
try:
_seed_reporter_database(data_dir)
except Exception as e:
logger.warning(f'[VYNDR] Reporter seeding skipped: {e}')
logger.info('[VYNDR] Cold start complete — engine ready to grade')
def _load_json(path, name):
"""Load a JSON data file. Log warning if missing."""
try:
with open(path) as f:
data = json.load(f)
logger.info(f'[VYNDR] Loaded {name} ({len(str(data))} bytes)')
return data
except FileNotFoundError:
logger.warning(f'[VYNDR] Data file not found: {path}')
return None
except json.JSONDecodeError as e:
logger.error(f'[VYNDR] Invalid JSON in {path}: {e}')
return None
def _seed_reporter_database(data_dir):
"""
Populate reporter_trust table from reporter_database.json.
Each reporter gets a starting trust tier based on their source_type.
Beat writers start at 'reliable'. Nationals start at 'authoritative'.
Aggregators start at 'unverified'.
"""
STARTING_TRUST = {
'beat_writer': 'reliable',
'national': 'authoritative',
'insider': 'reliable',
'aggregator': 'unverified'
}
path = os.path.join(data_dir, 'reporter_database.json')
try:
with open(path) as f:
reporters = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
logger.warning('[VYNDR] Reporter database not found for seeding')
return
from utils.supabase_client import get_supabase_client
supabase = get_supabase_client()
if not supabase:
logger.warning('[VYNDR] Supabase not available — reporter seeding skipped')
return
count = 0
for sport, teams in reporters.items():
if not isinstance(teams, dict):
continue
for team_id, team_reporters in teams.items():
if not isinstance(team_reporters, list):
continue
for reporter in team_reporters:
source_type = reporter.get('source_type', 'beat_writer')
starting_trust = STARTING_TRUST.get(source_type, 'unverified')
try:
supabase.table('reporter_trust').upsert({
'handle': reporter['handle'],
'sport': sport,
'team_id': team_id,
'outlet': reporter.get('outlet', ''),
'source_type': source_type,
'trust_level': starting_trust,
'starting_trust': starting_trust
}, on_conflict='handle').execute()
count += 1
except Exception as e:
logger.warning(f'[VYNDR] Failed to seed reporter {reporter.get("handle")}: {e}')
logger.info(f'[VYNDR] Seeded {count} reporters into reporter_trust')
# --- Main ---
if __name__ == '__main__':
cold_start_boot()
port = int(os.environ.get('PORT', 5001))
logger.info(f'[VYNDR] Starting Flask app on port {port}')
app.run(host='0.0.0.0', port=port, debug=False)
@@ -0,0 +1,237 @@
"""
VYNDR Auto-Calibration Engine
Point-biserial correlation for weight calibration.
Global offset. Brier score tracking. Blind spot detection.
"""
import logging
from datetime import datetime
from flask import Blueprint, request, jsonify
from utils.bayesian import (
calculate_global_offset, calculate_brier_score, GRADE_THRESHOLDS
)
from utils.blind_spot_detector import detect_model_blind_spots, track_catastrophic_misses
logger = logging.getLogger('vyndr')
calibration_bp = Blueprint('calibration', __name__)
# Calibration thresholds
PLAYER_CALIBRATION_THRESHOLDS = [25, 50, 75, 100]
GLOBAL_OFFSET_THRESHOLDS = [100, 250, 500, 1000]
POINT_BISERIAL_BOUNDS = {'min': 0.05, 'max': 0.50}
def calibrate_weights(player_id, sport, stat_type, outcomes, min_sample=25):
"""
Calibrate per-player weights using point-biserial correlation.
Bounds each weight between 0.05 and 0.50. Triggers at 25/50/75/100 resolved.
Args:
player_id: Player identifier.
sport: 'nba' or 'mlb'.
stat_type: Stat type string.
outcomes: List of resolved outcome dicts with 'hit' and 'sub_scores'.
min_sample: Minimum sample size (default 25).
Returns:
Dict of calibrated weights, or None if insufficient data.
"""
if len(outcomes) < min_sample:
return None
try:
from scipy.stats import pointbiserialr
except ImportError:
logger.warning('[VYNDR] scipy not available for calibration')
return None
hits = [1 if o['hit'] else 0 for o in outcomes]
sub_score_keys = list(outcomes[0].get('sub_scores', {}).keys())
if not sub_score_keys:
return None
correlations = {}
for key in sub_score_keys:
scores = [o.get('sub_scores', {}).get(key, 0.5) for o in outcomes]
try:
corr, p_value = pointbiserialr(hits, scores)
# Only use correlation if p < 0.10, otherwise use minimum bound
correlations[key] = abs(corr) if p_value < 0.10 else POINT_BISERIAL_BOUNDS['min']
except Exception:
correlations[key] = POINT_BISERIAL_BOUNDS['min']
# Clamp to bounds and normalize
clamped = {
k: max(POINT_BISERIAL_BOUNDS['min'], min(POINT_BISERIAL_BOUNDS['max'], v))
for k, v in correlations.items()
}
total = sum(clamped.values())
if total == 0:
return None
new_weights = {k: round(v / total, 4) for k, v in clamped.items()}
logger.info(
f'[VYNDR] Calibrated weights for {player_id}/{sport}/{stat_type} '
f'(n={len(outcomes)}): {new_weights}'
)
return new_weights
# --- Endpoints ---
@calibration_bp.route('/weights/<player_id>', methods=['GET'])
def get_player_weights(player_id):
"""
Get calibrated weights for a player, or defaults if not yet calibrated.
Args:
player_id: Player identifier.
Query params:
sport: 'nba' or 'mlb'.
stat_type: Stat type string.
Returns:
JSON with weights, source ('calibrated' or 'default'), and sample_size.
"""
sport = request.args.get('sport', 'nba')
stat_type = request.args.get('stat_type', 'points')
# In production, fetch from player_calibrated_weights table
return jsonify({
'player_id': player_id,
'sport': sport,
'stat_type': stat_type,
'weights': None,
'source': 'default',
'sample_size': 0,
'note': 'No calibrated weights yet — using archetype blend or defaults'
})
@calibration_bp.route('/resolve/<game_date>', methods=['POST'])
def resolve_grades(game_date):
"""
Trigger grade resolution for a specific date.
Called by the nightly resolution pipeline.
Args:
game_date: Date string (YYYY-MM-DD).
Returns:
JSON with resolution summary.
"""
# Delegate to resolution blueprint
return jsonify({
'game_date': game_date,
'status': 'resolution_triggered',
'note': 'Delegated to nightly resolution pipeline'
})
@calibration_bp.route('/global-offset/<sport>', methods=['GET'])
def get_global_offset(sport):
"""
Get the current global calibration offset for a sport.
Args:
sport: 'nba' or 'mlb'.
Returns:
JSON with offset_value, sample_size, calculated_at.
"""
return jsonify({
'sport': sport,
'offset_value': 0.0,
'sample_size': 0,
'calculated_at': None,
'note': 'No resolved grades yet — offset is 0.0'
})
@calibration_bp.route('/brier-score/<sport>', methods=['GET'])
def get_brier_score(sport):
"""
Get current Brier score for a sport.
Lower is better. 0.0 = perfect. 0.25 = coin flip.
Args:
sport: 'nba' or 'mlb'.
Returns:
JSON with brier_score, sample_size, interpretation.
"""
return jsonify({
'sport': sport,
'brier_score': None,
'sample_size': 0,
'interpretation': 'No resolved grades yet',
'tracked_from': 'day_one'
})
@calibration_bp.route('/blind-spots/<sport>', methods=['GET'])
def get_blind_spots(sport):
"""
Get identified blind spots where the model underperforms.
Only available after 200+ resolved grades.
Args:
sport: 'nba' or 'mlb'.
Returns:
JSON with blind_spots list and catastrophic_misses list.
"""
return jsonify({
'sport': sport,
'blind_spots': [],
'catastrophic_misses': [],
'sample_size': 0,
'minimum_required': 200,
'note': 'Insufficient data for blind spot detection'
})
@calibration_bp.route('/clv/<sport>', methods=['GET'])
def get_clv_report(sport):
"""
Get Closing Line Value report for a sport.
CLV measures whether the market moved toward our position.
Args:
sport: 'nba' or 'mlb'.
Returns:
JSON with CLV stats.
"""
return jsonify({
'sport': sport,
'total_grades_with_clv': 0,
'clv_win_rate': None,
'avg_clv_magnitude': None,
'note': 'CLV tracking begins when odds_warehouse has morning + pre-game data'
})
@calibration_bp.route('/alignment/<sport>', methods=['GET'])
def get_alignment_report(sport):
"""
Get model-market alignment stats.
Shows how often the market moves WITH vs AGAINST VYNDR's position.
Args:
sport: 'nba' or 'mlb'.
Returns:
JSON with alignment stats.
"""
return jsonify({
'sport': sport,
'confirming_count': 0,
'contrarian_count': 0,
'alignment_rate': None,
'note': 'Alignment tracking begins with odds_warehouse data'
})
+938
View File
@@ -0,0 +1,938 @@
"""
VYNDR Coaching Tendency Database — tactical fingerprinting for every coach.
Blueprint tracks coaching decisions game-over-game, detects mid-season
philosophy shifts, and feeds tendency data into prop grading models.
Supports both NBA and MLB with sport-specific field sets.
"""
import logging
from collections import Counter
from datetime import datetime, date, timedelta
from flask import Blueprint, request, jsonify
from utils.data_warehouse import fetch_with_cache
from utils.retry import api_call_with_retry
from utils.supabase_client import get_supabase_client
logger = logging.getLogger('vyndr')
coaching_bp = Blueprint('coaching', __name__)
# ---------------------------------------------------------------------------
# Field definitions per sport
# ---------------------------------------------------------------------------
COACHING_FIELDS = {
'nba': {
'pace_preference': {
'type': 'float',
'description': 'Possessions per 48 minutes — fast (100+) vs grind-it-out (<95)',
},
'three_point_rate': {
'type': 'float',
'description': 'Fraction of field goal attempts from three-point range',
},
'isolation_frequency': {
'type': 'float',
'description': 'Percentage of possessions ending in isolation plays',
},
'pick_roll_usage': {
'type': 'float',
'description': 'Percentage of possessions using pick-and-roll actions',
},
'bench_rotation_depth': {
'type': 'int',
'description': 'Number of players receiving 10+ minutes per game',
},
'fouling_philosophy_late': {
'type': 'str',
'description': 'Late-game fouling tendency: aggressive, selective, passive',
},
'score_state_rotations': {
'type': 'dict',
'description': 'Lineup groups by score differential bucket (blowout/close/trailing)',
},
'late_game_possession_player': {
'type': 'str',
'description': 'Player who most often gets the ball in crunch time (last 2 min, within 5 pts)',
},
'second_unit_usage_pattern': {
'type': 'str',
'description': 'When and how the second unit is deployed — stagger vs full-bench',
},
'usage_redistribution_profile': {
'type': 'dict',
'description': 'How usage shifts when a starter sits — who absorbs touches',
},
'shot_location_allowances': {
'type': 'dict',
'description': 'Defensive scheme — rim protection vs perimeter switching emphasis',
},
'timeout_tendency': {
'type': 'str',
'description': 'Timeout calling pattern — early to stop runs, or ride momentum',
},
},
'mlb': {
'starter_hook_tendency': {
'type': 'float',
'description': 'Average innings before pulling the starter',
},
'quick_hook_threshold': {
'type': 'float',
'description': 'ERA / pitch-count threshold that triggers early pull',
},
'bullpen_usage_philosophy': {
'type': 'str',
'description': 'Matchup-based, innings-based, or closer-only mentality',
},
'intentional_walk_rate': {
'type': 'float',
'description': 'Intentional walks per 9 innings managed',
},
'pinch_hit_frequency': {
'type': 'float',
'description': 'Pinch-hit substitutions per game average',
},
'bunt_tendency': {
'type': 'float',
'description': 'Sacrifice bunts per game average',
},
'save_situation_closer_only': {
'type': 'bool',
'description': 'Whether manager uses closer exclusively in save situations',
},
'platoon_tendency': {
'type': 'float',
'description': 'Rate of platoon-advantaged lineup construction',
},
'lineup_consistency': {
'type': 'float',
'description': 'Percentage of games with identical top-6 batting order',
},
'challenge_aggressiveness': {
'type': 'float',
'description': 'Replay challenges per game average',
},
'high_leverage_hook_tendency': {
'type': 'float',
'description': 'How quickly manager pulls starter with runners on. '
'Low = lets starter work through trouble (higher K ceiling). '
'High = quick hook (reduced K ceiling, more bullpen exposure).',
},
},
}
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@coaching_bp.route('/tendencies/<coach_id>', methods=['GET'])
def get_coaching_tendencies(coach_id):
"""
Fetch coaching tendencies for a specific coach.
Query params:
sport (str): 'nba' or 'mlb'. Required.
Returns:
JSON with coach_id, sport, tendencies dict, and updated_at timestamp.
"""
sport = request.args.get('sport', '').lower()
if sport not in ('nba', 'mlb'):
return jsonify({'error': 'sport query param required — nba or mlb'}), 400
cache_key = f'coaching_tendencies:{sport}:{coach_id}'
def _fetch_tendencies():
"""Pull coaching tendencies from Supabase."""
client = get_supabase_client()
if not client:
logger.warning('[Coaching] Supabase client unavailable')
return None
try:
resp = (
client.table('coaching_tendencies')
.select('*')
.eq('coach_id', coach_id)
.eq('sport', sport)
.order('updated_at', desc=True)
.limit(1)
.execute()
)
if resp.data:
return resp.data[0]
return None
except Exception as exc:
logger.error(f'[Coaching] Failed to fetch tendencies for {coach_id}: {exc}')
return None
data = fetch_with_cache(cache_key, _fetch_tendencies, data_type='player_stats')
if not data:
return jsonify({'error': 'No coaching tendencies found', 'coach_id': coach_id}), 404
return jsonify({
'coach_id': coach_id,
'sport': sport,
'tendencies': data.get('tendencies', {}),
'updated_at': data.get('updated_at'),
})
@coaching_bp.route('/shift-detection/<team_id>', methods=['GET'])
def detect_coaching_shifts(team_id):
"""
Compare the last 15 games to the season baseline and flag any field
where the recent value deviates by 15 %+ from the baseline.
Query params:
sport (str): 'nba' or 'mlb'. Required.
Returns:
JSON with team_id, sport, and a list of detected shifts. Each shift
contains field, baseline, recent, change_pct, and direction.
"""
sport = request.args.get('sport', '').lower()
if sport not in ('nba', 'mlb'):
return jsonify({'error': 'sport query param required — nba or mlb'}), 400
baseline = get_season_baseline(team_id, sport)
recent = get_recent_tendencies(team_id, sport, window=15)
if not baseline or not recent:
return jsonify({
'error': 'Insufficient data for shift detection',
'team_id': team_id,
}), 404
shifts = []
numeric_fields = [
f for f, meta in COACHING_FIELDS.get(sport, {}).items()
if meta['type'] in ('float', 'int')
]
for field in numeric_fields:
base_val = baseline.get(field)
recent_val = recent.get(field)
if base_val is None or recent_val is None:
continue
try:
base_val = float(base_val)
recent_val = float(recent_val)
except (TypeError, ValueError):
continue
if base_val == 0:
continue
change_pct = abs(recent_val - base_val) / abs(base_val) * 100
if change_pct >= 15.0:
direction = 'up' if recent_val > base_val else 'down'
shifts.append({
'field': field,
'baseline': round(base_val, 4),
'recent': round(recent_val, 4),
'change_pct': round(change_pct, 2),
'direction': direction,
})
shifts.sort(key=lambda s: s['change_pct'], reverse=True)
return jsonify({
'team_id': team_id,
'sport': sport,
'window': 15,
'threshold_pct': 15.0,
'shifts': shifts,
})
# ---------------------------------------------------------------------------
# Season baseline & recent tendencies helpers
# ---------------------------------------------------------------------------
def get_season_baseline(team_id, sport):
"""
Retrieve the full-season average coaching tendencies for a team.
Args:
team_id: Team identifier string.
sport: 'nba' or 'mlb'.
Returns:
Dict of field -> averaged value across all games this season,
or None if data unavailable.
"""
client = get_supabase_client()
if not client:
return None
try:
resp = (
client.table('coaching_tendencies')
.select('tendencies')
.eq('team_id', team_id)
.eq('sport', sport)
.execute()
)
if not resp.data:
return None
return _average_tendency_rows(resp.data, sport)
except Exception as exc:
logger.error(f'[Coaching] Season baseline fetch failed for {team_id}: {exc}')
return None
def get_recent_tendencies(team_id, sport, window=15):
"""
Retrieve coaching tendencies from the most recent N games.
Args:
team_id: Team identifier string.
sport: 'nba' or 'mlb'.
window: Number of recent games to include.
Returns:
Dict of field -> averaged value across the window,
or None if data unavailable.
"""
client = get_supabase_client()
if not client:
return None
try:
resp = (
client.table('coaching_tendencies')
.select('tendencies')
.eq('team_id', team_id)
.eq('sport', sport)
.order('game_date', desc=True)
.limit(window)
.execute()
)
if not resp.data:
return None
return _average_tendency_rows(resp.data, sport)
except Exception as exc:
logger.error(f'[Coaching] Recent tendencies fetch failed for {team_id}: {exc}')
return None
def _average_tendency_rows(rows, sport):
"""
Average numeric tendency fields across multiple game rows.
Args:
rows: List of dicts, each containing a 'tendencies' dict.
sport: 'nba' or 'mlb'.
Returns:
Dict of field -> averaged numeric value. Non-numeric fields use
the most recent value.
"""
if not rows:
return None
numeric_fields = [
f for f, meta in COACHING_FIELDS.get(sport, {}).items()
if meta['type'] in ('float', 'int')
]
sums = {f: 0.0 for f in numeric_fields}
counts = {f: 0 for f in numeric_fields}
result = {}
for row in rows:
tendencies = row.get('tendencies', {})
if not tendencies:
continue
for field in numeric_fields:
val = tendencies.get(field)
if val is not None:
try:
sums[field] += float(val)
counts[field] += 1
except (TypeError, ValueError):
pass
for field in numeric_fields:
if counts[field] > 0:
result[field] = round(sums[field] / counts[field], 4)
# For non-numeric fields, take the most recent value
most_recent = rows[0].get('tendencies', {}) if rows else {}
non_numeric = [
f for f, meta in COACHING_FIELDS.get(sport, {}).items()
if meta['type'] not in ('float', 'int')
]
for field in non_numeric:
val = most_recent.get(field)
if val is not None:
result[field] = val
return result
# ---------------------------------------------------------------------------
# Nightly update pipeline
# ---------------------------------------------------------------------------
def update_coaching_tendencies(game_date):
"""
Nightly job: iterate all completed games for the given date,
parse coaching decisions from both sides, and upsert to Supabase.
Args:
game_date: date object or ISO string (YYYY-MM-DD) for the target day.
"""
if isinstance(game_date, str):
game_date = datetime.strptime(game_date, '%Y-%m-%d').date()
logger.info(f'[Coaching] Running nightly update for {game_date.isoformat()}')
# Fetch completed games for the date
nba_games = _fetch_completed_games(game_date, 'nba')
mlb_games = _fetch_completed_games(game_date, 'mlb')
processed = 0
for game in nba_games:
for side in ('home', 'away'):
try:
tendencies = parse_nba_coaching_decisions(game, side)
if tendencies:
upsert_coaching_tendencies(
coach_id=tendencies.pop('coach_id', None),
team_id=tendencies.pop('team_id', None),
sport='nba',
game_id=game.get('game_id'),
game_date=game_date,
tendencies=tendencies,
)
processed += 1
except Exception as exc:
logger.error(
f'[Coaching] NBA parse failed game={game.get("game_id")} '
f'side={side}: {exc}'
)
for game in mlb_games:
for side in ('home', 'away'):
try:
tendencies = parse_mlb_coaching_decisions(game, side)
if tendencies:
upsert_coaching_tendencies(
coach_id=tendencies.pop('coach_id', None),
team_id=tendencies.pop('team_id', None),
sport='mlb',
game_id=game.get('game_id'),
game_date=game_date,
tendencies=tendencies,
)
processed += 1
except Exception as exc:
logger.error(
f'[Coaching] MLB parse failed game={game.get("game_id")} '
f'side={side}: {exc}'
)
logger.info(f'[Coaching] Nightly update complete — {processed} entries upserted')
return processed
def _fetch_completed_games(game_date, sport):
"""
Retrieve completed games for a given date and sport from the data warehouse.
Args:
game_date: date object.
sport: 'nba' or 'mlb'.
Returns:
List of game dicts with box score / play-by-play data attached.
"""
cache_key = f'completed_games:{sport}:{game_date.isoformat()}'
def _fetch():
client = get_supabase_client()
if not client:
return []
try:
resp = (
client.table('games')
.select('*')
.eq('sport', sport)
.eq('game_date', game_date.isoformat())
.eq('status', 'completed')
.execute()
)
return resp.data or []
except Exception as exc:
logger.error(f'[Coaching] Game fetch failed for {sport} {game_date}: {exc}')
return []
return fetch_with_cache(cache_key, _fetch, data_type='player_stats') or []
# ---------------------------------------------------------------------------
# NBA coaching decision parsing
# ---------------------------------------------------------------------------
def parse_nba_coaching_decisions(game, side):
"""
Extract coaching tendency signals from an NBA game's box score
and play-by-play data for one side (home or away).
Args:
game: Game dict with nested box score and play-by-play.
side: 'home' or 'away'.
Returns:
Dict of coaching tendency fields, or None if data insufficient.
"""
box = game.get(f'{side}_box', {})
pbp = game.get('play_by_play', [])
team_id = game.get(f'{side}_team_id')
coach_id = game.get(f'{side}_coach_id')
if not box or not team_id:
return None
players = box.get('players', [])
if not players:
return None
# Rotation depth: players with 10+ minutes
rotation_depth = sum(1 for p in players if (p.get('minutes', 0) or 0) >= 10)
# Late game possession player (last 2 min, within 5 pts)
late_game_player = _find_late_game_possession_player(pbp, team_id)
# Pace: possessions per 48 from box score
pace = box.get('pace', None)
# Three-point rate
three_rate = calculate_three_rate(players)
# Score-state lineups
score_state = extract_score_state_lineups(pbp, team_id)
tendencies = {
'coach_id': coach_id,
'team_id': team_id,
'bench_rotation_depth': rotation_depth,
'late_game_possession_player': late_game_player,
'pace_preference': pace,
'three_point_rate': three_rate,
'score_state_rotations': score_state,
}
# Additional fields parsed from play-by-play when available
iso_freq = box.get('isolation_frequency')
if iso_freq is not None:
tendencies['isolation_frequency'] = iso_freq
pr_usage = box.get('pick_roll_usage')
if pr_usage is not None:
tendencies['pick_roll_usage'] = pr_usage
return tendencies
def _find_late_game_possession_player(pbp, team_id):
"""
Identify the player who most frequently has the ball in crunch time
(last 2 minutes of 4th quarter / OT, score within 5 points).
Args:
pbp: List of play-by-play event dicts.
team_id: Team identifier to filter possessions.
Returns:
Player name string or None.
"""
crunch_possessions = []
for event in pbp:
period = event.get('period', 0)
clock = event.get('clock', '')
margin = abs(event.get('score_margin', 999))
event_team = event.get('team_id')
if event_team != team_id:
continue
if period < 4:
continue
if margin > 5:
continue
# Parse clock — expect "MM:SS" or seconds remaining
remaining = _parse_clock(clock)
if remaining is not None and remaining <= 120:
player = event.get('player_name') or event.get('player_id')
if player:
crunch_possessions.append(player)
return most_common_player(crunch_possessions)
def _parse_clock(clock):
"""
Parse game clock string into seconds remaining.
Args:
clock: String like '1:45' or numeric seconds.
Returns:
Float seconds remaining, or None if unparseable.
"""
if clock is None:
return None
if isinstance(clock, (int, float)):
return float(clock)
try:
parts = str(clock).split(':')
if len(parts) == 2:
return int(parts[0]) * 60 + float(parts[1])
return float(clock)
except (ValueError, TypeError):
return None
# ---------------------------------------------------------------------------
# MLB coaching decision parsing
# ---------------------------------------------------------------------------
def parse_mlb_coaching_decisions(game, side):
"""
Extract coaching tendency signals from an MLB game for one side.
Args:
game: Game dict with box score and play-by-play data.
side: 'home' or 'away'.
Returns:
Dict of coaching tendency fields, or None if data insufficient.
"""
box = game.get(f'{side}_box', {})
pbp = game.get('play_by_play', [])
team_id = game.get(f'{side}_team_id')
coach_id = game.get(f'{side}_coach_id')
if not box or not team_id:
return None
pitching = box.get('pitching', {})
batting = box.get('batting', {})
# Starter hook tendency — innings pitched by the starter
starter = pitching.get('starter', {})
starter_ip = starter.get('innings_pitched', None)
# Pinch-hit frequency
pinch_hits = count_pinch_hits(pbp, team_id)
# Bunt tendency
sac_bunts = count_sacrifice_bunts(pbp, team_id)
# Challenge aggressiveness
challenges = box.get('challenges_used', 0) or 0
tendencies = {
'coach_id': coach_id,
'team_id': team_id,
'starter_hook_tendency': float(starter_ip) if starter_ip is not None else None,
'pinch_hit_frequency': pinch_hits,
'bunt_tendency': sac_bunts,
'challenge_aggressiveness': challenges,
}
# Intentional walks from pitching data
ibb = pitching.get('intentional_walks', None)
if ibb is not None:
tendencies['intentional_walk_rate'] = float(ibb)
return tendencies
# ---------------------------------------------------------------------------
# Shared helper functions
# ---------------------------------------------------------------------------
def most_common_player(player_list):
"""
Return the most frequently occurring player name from a list.
Args:
player_list: List of player name strings.
Returns:
Most common player name, or None if list is empty.
"""
if not player_list:
return None
counter = Counter(player_list)
return counter.most_common(1)[0][0]
def extract_score_state_lineups(pbp, team_id):
"""
Group on-court lineups by score-state buckets for a given team.
Score-state buckets:
- blowout_ahead: team leading by 15+
- comfortable: team leading by 6-14
- close: margin within 5
- trailing: team down by 6-14
- blowout_behind: team down by 15+
Args:
pbp: Play-by-play event list.
team_id: Team identifier.
Returns:
Dict mapping bucket name to the most common lineup (list of player names)
seen in that bucket, or empty dict if no data.
"""
buckets = {
'blowout_ahead': [],
'comfortable': [],
'close': [],
'trailing': [],
'blowout_behind': [],
}
for event in pbp:
if event.get('team_id') != team_id:
continue
lineup = event.get('lineup', [])
if not lineup:
continue
margin = event.get('score_margin', 0) or 0
lineup_key = tuple(sorted(lineup))
if margin >= 15:
buckets['blowout_ahead'].append(lineup_key)
elif margin >= 6:
buckets['comfortable'].append(lineup_key)
elif margin >= -5:
buckets['close'].append(lineup_key)
elif margin >= -14:
buckets['trailing'].append(lineup_key)
else:
buckets['blowout_behind'].append(lineup_key)
result = {}
for bucket, lineups in buckets.items():
if lineups:
counter = Counter(lineups)
most_common = counter.most_common(1)[0][0]
result[bucket] = list(most_common)
return result
def calculate_three_rate(players):
"""
Calculate three-point attempt rate from player box score data.
Args:
players: List of player box score dicts with 'fga' and 'fg3a' fields.
Returns:
Float three-point rate (0.0-1.0), or None if no FGA data.
"""
total_fga = 0
total_fg3a = 0
for p in players:
fga = p.get('fga', 0) or 0
fg3a = p.get('fg3a', 0) or 0
total_fga += fga
total_fg3a += fg3a
if total_fga == 0:
return None
return round(total_fg3a / total_fga, 4)
def count_pinch_hits(pbp, team_id):
"""
Count pinch-hit substitutions for a team from play-by-play data.
Args:
pbp: Play-by-play event list.
team_id: Team identifier.
Returns:
Integer count of pinch-hit appearances.
"""
count = 0
for event in pbp:
if event.get('team_id') != team_id:
continue
event_type = (event.get('event_type') or '').lower()
description = (event.get('description') or '').lower()
if 'pinch' in event_type or 'pinch hit' in description:
count += 1
return count
def count_sacrifice_bunts(pbp, team_id):
"""
Count sacrifice bunt attempts for a team from play-by-play data.
Args:
pbp: Play-by-play event list.
team_id: Team identifier.
Returns:
Integer count of sacrifice bunts.
"""
count = 0
for event in pbp:
if event.get('team_id') != team_id:
continue
event_type = (event.get('event_type') or '').lower()
description = (event.get('description') or '').lower()
if 'sacrifice' in event_type and 'bunt' in event_type:
count += 1
elif 'sac bunt' in description or 'sacrifice bunt' in description:
count += 1
return count
# ---------------------------------------------------------------------------
# Supabase persistence
# ---------------------------------------------------------------------------
def upsert_coaching_tendencies(coach_id, team_id, sport, game_id, game_date, tendencies):
"""
Upsert coaching tendency data into the Supabase coaching_tendencies table.
Uses (coach_id, sport, game_id) as the conflict key so re-processing a
date is idempotent.
Args:
coach_id: Coach identifier string.
team_id: Team identifier string.
sport: 'nba' or 'mlb'.
game_id: Unique game identifier.
game_date: date object for the game.
tendencies: Dict of tendency field -> value.
Returns:
True on success, False on failure.
"""
client = get_supabase_client()
if not client:
logger.warning('[Coaching] Cannot upsert — Supabase client unavailable')
return False
if isinstance(game_date, date):
game_date_str = game_date.isoformat()
else:
game_date_str = str(game_date)
row = {
'coach_id': coach_id,
'team_id': team_id,
'sport': sport,
'game_id': game_id,
'game_date': game_date_str,
'tendencies': tendencies,
'updated_at': datetime.utcnow().isoformat(),
}
try:
client.table('coaching_tendencies').upsert(
row, on_conflict='coach_id,sport,game_id'
).execute()
logger.info(
f'[Coaching] Upserted tendencies coach={coach_id} game={game_id}'
)
return True
except Exception as exc:
logger.error(
f'[Coaching] Upsert failed coach={coach_id} game={game_id}: {exc}'
)
return False
# ---------------------------------------------------------------------------
# PATCH Item 15: Historical seeding wrappers
# ---------------------------------------------------------------------------
def parse_nba_coaching_from_game_id(game_id):
"""
Wrapper for historical seeding — fetches NBA game data then parses.
Called by scripts/seed_historical.py.
Args:
game_id: NBA game ID string.
"""
import time
time.sleep(0.6)
try:
from nba_api.stats.endpoints import BoxScoreTraditionalV2, PlayByPlayV2
box = BoxScoreTraditionalV2(game_id=game_id)
time.sleep(0.6)
pbp = PlayByPlayV2(game_id=game_id)
box_dfs = box.get_data_frames()
pbp_df = pbp.get_data_frames()[0]
game_data = {
'boxscore': _format_box_for_coaching(box_dfs),
'play_by_play': _format_pbp_for_coaching(pbp_df),
'game_date': None
}
for side in ['home', 'away']:
tendencies = parse_nba_coaching_decisions(game_data, side)
coach_id = game_data.get(f'{side}_coach_id', f'unknown_{side}')
team_id = game_data.get(f'{side}_team_id', f'unknown_{side}')
upsert_coaching_tendencies(
coach_id, team_id, 'nba', tendencies,
game_data.get('game_date'), game_id
)
except Exception as e:
logger.warning(f'[Coaching] NBA historical parse failed for {game_id}: {e}')
def parse_mlb_coaching_from_game_id(game_id):
"""
Wrapper for historical seeding — fetches MLB game data then parses.
Called by scripts/seed_historical.py.
Args:
game_id: MLB game ID (gamePk).
"""
try:
import statsapi
game_data = statsapi.get('game', {'gamePk': game_id})
for side in ['home', 'away']:
tendencies = parse_mlb_coaching_decisions(game_data, side)
team_data = game_data.get('gameData', {}).get('teams', {}).get(side, {})
coach_id = str(team_data.get('id', f'unknown_{side}'))
team_id = str(team_data.get('id', f'unknown_{side}'))
game_date = game_data.get('gameData', {}).get('datetime', {}).get('officialDate')
upsert_coaching_tendencies(
coach_id, team_id, 'mlb', tendencies, game_date, str(game_id)
)
except Exception as e:
logger.warning(f'[Coaching] MLB historical parse failed for {game_id}: {e}')
def _format_box_for_coaching(box_dfs):
"""Format BoxScoreTraditionalV2 DataFrames for coaching parser."""
return {}
def _format_pbp_for_coaching(pbp_df):
"""Format PlayByPlayV2 DataFrame for coaching parser."""
return []
+400
View File
@@ -0,0 +1,400 @@
"""
VYNDR Evolution Engine — Blueprint
PELT changepoint detection for player metric evolution.
Structural migration from evolutionEngine.py — logic unchanged.
"""
import sys
import numpy as np
from flask import Blueprint, request, jsonify
evolution_bp = Blueprint('evolution', __name__)
# Graceful import — ruptures may not be installed
try:
import ruptures as rpt
HAS_RUPTURES = True
except ImportError:
HAS_RUPTURES = False
print("[evolution-engine] WARNING: ruptures not installed. Using fallback.", file=sys.stderr)
def detect_changepoints_pelt(values, min_size=5, penalty=3.0):
"""
Use PELT algorithm from ruptures library.
Detects changepoints in time-series data for player metric evolution.
Args:
values: List of numeric values (e.g., game-by-game stat line).
min_size: Minimum segment length between changepoints.
penalty: PELT penalty parameter — higher = fewer changepoints.
Returns:
Dict with changepoints list, confidence scores, and algorithm used.
"""
if not HAS_RUPTURES:
return fallback_detect(values)
signal = np.array(values, dtype=float)
if len(signal) < min_size * 2:
return {"changepoints": [], "confidence": [], "algorithm": "PELT"}
algo = rpt.Pelt(model="rbf", min_size=min_size).fit(signal)
result = algo.predict(pen=penalty)
# Remove the last element (always = len(signal))
changepoints = [cp for cp in result if cp < len(signal)]
# Calculate confidence for each changepoint
confidences = []
for cp in changepoints:
left = signal[max(0, cp - min_size):cp]
right = signal[cp:min(len(signal), cp + min_size)]
if len(left) > 0 and len(right) > 0:
diff = abs(np.mean(right) - np.mean(left))
std = max(np.std(signal), 0.01)
conf = min(diff / std, 1.0)
confidences.append(round(conf, 3))
else:
confidences.append(0.0)
return {
"changepoints": changepoints,
"confidence": confidences,
"algorithm": "PELT",
}
def fallback_detect(values):
"""Simple window-based fallback when ruptures unavailable."""
if len(values) < 10:
return {"changepoints": [], "confidence": [], "algorithm": "fallback"}
signal = np.array(values, dtype=float)
window = max(5, len(signal) // 5)
changepoints = []
confidences = []
for i in range(window, len(signal) - window):
left_mean = np.mean(signal[i - window:i])
right_mean = np.mean(signal[i:i + window])
std = max(np.std(signal), 0.01)
diff = abs(right_mean - left_mean)
if diff / std > 1.5:
changepoints.append(i)
confidences.append(min(round(diff / std / 3.0, 3), 1.0))
# Deduplicate nearby changepoints
filtered_cp = []
filtered_conf = []
for cp, conf in zip(changepoints, confidences):
if not filtered_cp or cp - filtered_cp[-1] >= window:
filtered_cp.append(cp)
filtered_conf.append(conf)
return {
"changepoints": filtered_cp,
"confidence": filtered_conf,
"algorithm": "fallback",
}
@evolution_bp.route("/health", methods=["GET"])
def evolution_health():
"""Health check for evolution engine subsystem."""
return jsonify({
"status": "ok",
"ruptures_available": HAS_RUPTURES,
})
@evolution_bp.route("/detect-changepoints", methods=["POST"])
def detect_changepoints():
"""
Detect changepoints in a time-series of player metrics.
Request body:
values: List[float] — metric values in chronological order.
min_size: int (optional, default 5) — minimum segment length.
penalty: float (optional, default 3.0) — PELT penalty.
player_id: str (optional) — for logging.
metric: str (optional) — metric name for logging.
Returns:
changepoints: List[int] — indices where regime changes detected.
confidence: List[float] — confidence score per changepoint.
algorithm: str — 'PELT' or 'fallback'.
"""
data = request.get_json()
if not data:
return jsonify({"error": "JSON body required"}), 400
values = data.get("values", [])
if not values or len(values) < 5:
return jsonify({
"changepoints": [],
"confidence": [],
"algorithm": "PELT",
"note": "Insufficient data points",
})
result = detect_changepoints_pelt(
values,
min_size=data.get("min_size", 5),
penalty=data.get("penalty", 3.0),
)
result["player_id"] = data.get("player_id")
result["metric"] = data.get("metric")
return jsonify(result)
# ============================================================
# SUPPLEMENT: Player Evolution Alerting
# ============================================================
import json
import logging
from datetime import date
logger = logging.getLogger('vyndr')
# Metrics to scan per sport
EVOLUTION_METRICS = {
'nba': ['usage_rate', 'assist_rate', 'three_pa_rate', 'fg_pct', 'minutes'],
'mlb': ['k_rate', 'bb_rate', 'exit_velocity', 'hard_hit_pct', 'fb_velo']
}
EVOLUTION_MIN_GAMES = 15
EVOLUTION_CHANGE_THRESHOLD = 0.10 # 10% change
EVOLUTION_MIN_CONCURRENT = 2 # 2+ metrics must inflect
def detect_player_evolution(player_id, sport, metric_data=None):
"""
Use PELT to detect inflection points across multiple metrics simultaneously.
Flag PLAYER_EVOLUTION_DETECTED when 2+ metrics show concurrent inflection
(10%+ change in last 5 games vs prior window, minimum 15 games total).
Args:
player_id: Player identifier.
sport: 'nba' or 'mlb'.
metric_data: Optional dict mapping metric name to list of values.
If None, would be fetched from data warehouse in production.
Returns:
Dict with evolution_detected (bool) and inflection details if detected.
"""
metrics = EVOLUTION_METRICS.get(sport, [])
inflections = {}
for metric in metrics:
if metric_data and metric in metric_data:
values = metric_data[metric]
else:
values = _get_player_metric_series(player_id, metric)
if len(values) < EVOLUTION_MIN_GAMES:
continue
result = detect_changepoints_pelt(values)
changepoints = result.get('changepoints', [])
if changepoints:
latest_cp = max(changepoints)
# Inflection must be in last 5 games of the series
if latest_cp >= len(values) - 5:
before_vals = values[:latest_cp]
after_vals = values[latest_cp:]
if before_vals and after_vals:
before_mean = float(np.mean(before_vals))
after_mean = float(np.mean(after_vals))
denominator = max(abs(before_mean), 0.01)
pct_change = (after_mean - before_mean) / denominator
if abs(pct_change) > EVOLUTION_CHANGE_THRESHOLD:
inflections[metric] = {
'before': round(before_mean, 3),
'after': round(after_mean, 3),
'change_pct': round(pct_change * 100, 1),
'direction': 'ascending' if pct_change > 0 else 'descending',
'changepoint_game': latest_cp
}
if len(inflections) >= EVOLUTION_MIN_CONCURRENT:
return {
'evolution_detected': True,
'player_id': player_id,
'sport': sport,
'detection_date': date.today().isoformat(),
'metrics_inflecting': len(inflections),
'inflections': inflections,
}
return {'evolution_detected': False, 'player_id': player_id}
def log_evolution_detection(evolution):
"""
Create timestamped, verifiable record in evolution_detections table.
After one season: 'We detected X inflection points. Y confirmed by market movement.'
Args:
evolution: Dict from detect_player_evolution with evolution_detected=True.
"""
try:
from utils.supabase_client import get_supabase_client
supabase = get_supabase_client()
if supabase:
supabase.table('evolution_detections').insert({
'player_id': evolution['player_id'],
'player_name': evolution.get('player_name'),
'sport': evolution['sport'],
'detection_date': evolution['detection_date'],
'metrics': json.dumps(evolution['inflections']),
'market_adjusted_at': None,
'confirmed': None
}).execute()
except Exception as e:
logger.warning(f'[VYNDR] Evolution detection log failed: {e}')
def format_evolution_watch_post(evolutions):
"""
Weekly content: 'VYNDR Evolution Watch'
Players whose stats are inflecting before market adjustment.
Args:
evolutions: List of evolution detection dicts.
Returns:
Formatted post string, or None if no evolutions.
"""
if not evolutions:
return None
lines = ["\U0001f52c VYNDR Evolution Watch\n"]
lines.append("Players inflecting before the market catches up:\n")
for evo in evolutions[:5]:
metrics = evo.get('inflections', {})
if not metrics:
continue
top_metric = max(metrics.items(), key=lambda x: abs(x[1]['change_pct']))
direction = '\U0001f4c8' if top_metric[1]['direction'] == 'ascending' else '\U0001f4c9'
lines.append(
f"{direction} {evo.get('player_name', evo['player_id'])} \u2014 "
f"{top_metric[0]}: {top_metric[1]['before']} \u2192 {top_metric[1]['after']} "
f"({top_metric[1]['change_pct']:+.1f}%)"
)
lines.append("\nThe model sees it. The market hasn't priced it yet.")
return '\n'.join(lines)
def _get_player_metric_series(player_id, metric, n_games=30):
"""Stub: fetch player metric time series from data warehouse."""
return []
@evolution_bp.route("/scan/<sport>", methods=["GET"])
def scan_for_evolutions(sport):
"""
Scan all active players for evolution inflection points.
Run daily. Creates timestamped records for accuracy ledger.
Args:
sport: 'nba' or 'mlb'.
Returns:
JSON with scan results and detected evolutions.
"""
# In production, get_active_players fetches from Supabase
evolutions = []
return jsonify({
'sport': sport,
'scan_date': date.today().isoformat(),
'evolutions_detected': len(evolutions),
'evolutions': evolutions,
'note': 'Connect to player data source for live scanning'
})
@evolution_bp.route("/watch-post", methods=["GET"])
def get_evolution_watch():
"""Get formatted Evolution Watch post for capper account."""
return jsonify({
'post': None,
'note': 'No evolutions detected yet'
})
# ============================================================
# PATCH Item 8: Evolution Persistence Check
# ============================================================
EVOLUTION_PERSISTENCE_GAMES = 3 # games before public promotion
def promote_evolution_to_public(evolution_record, games_since_detection):
"""
Evolution detected internally on day X.
Only promote to Evolution Watch content after 3 games of persistence.
If inflection didn't hold, mark as false positive.
Args:
evolution_record: Dict with player_id, detection_date, inflections.
games_since_detection: Number of games played since detection.
Returns:
Dict with promoted (bool) and reason.
"""
if games_since_detection < EVOLUTION_PERSISTENCE_GAMES:
return {
'promoted': False,
'reason': f'Persistence check: {games_since_detection}/{EVOLUTION_PERSISTENCE_GAMES} games'
}
# Check if inflection held (would verify against recent data in production)
still_inflecting = verify_inflection_persists(evolution_record)
if still_inflecting:
return {'promoted': True, 'games_verified': games_since_detection}
else:
return {
'promoted': False,
'reason': 'Inflection did not persist — false positive',
'false_positive': True
}
def verify_inflection_persists(evolution_record):
"""
Verify that detected inflection points are still present in recent data.
Returns True if the change direction is maintained.
Args:
evolution_record: Dict with inflections data.
Returns:
True if inflection persists, False if reverted.
"""
inflections = evolution_record.get('inflections', {})
if not inflections:
return False
# In production: re-fetch recent metric values and compare to post-inflection mean
# For now, stub returns True (would be replaced with actual data check)
return True
def scan_for_evolutions_internal(sport):
"""
Internal version of evolution scan called by nightly resolution.
Does not require Flask request context.
Args:
sport: 'nba' or 'mlb'.
"""
logger.info(f'[VYNDR] Running evolution scan for {sport}')
# In production: iterate active players, call detect_player_evolution
@@ -0,0 +1,173 @@
"""
VYNDR Image-to-Grade OCR
Accept bet slip screenshot → preprocess → OCR → parse → fuzzy match → grade.
"""
import logging
import io
from flask import Blueprint, request, jsonify
logger = logging.getLogger('vyndr')
image_grade_bp = Blueprint('image_grade', __name__)
def preprocess_image(image_bytes):
"""
Preprocess image for OCR: grayscale, contrast enhancement, threshold.
Args:
image_bytes: Raw image bytes.
Returns:
PIL Image ready for OCR.
"""
try:
from PIL import Image, ImageEnhance, ImageFilter
img = Image.open(io.BytesIO(image_bytes))
img = img.convert('L') # grayscale
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(2.0)
img = img.filter(ImageFilter.SHARPEN)
return img
except ImportError:
logger.error('[VYNDR] Pillow not installed')
return None
def ocr_image(image):
"""
Run OCR on preprocessed image.
Args:
image: PIL Image.
Returns:
Dict with text and confidence.
"""
try:
import pytesseract
text = pytesseract.image_to_string(image)
data = pytesseract.image_to_data(image, output_type=pytesseract.Output.DICT)
confidences = [int(c) for c in data['conf'] if int(c) > 0]
avg_conf = sum(confidences) / len(confidences) if confidences else 0
return {'text': text.strip(), 'confidence': round(avg_conf, 1)}
except ImportError:
logger.error('[VYNDR] pytesseract not installed')
return {'text': '', 'confidence': 0}
except Exception as e:
logger.error(f'[VYNDR] OCR failed: {e}')
return {'text': '', 'confidence': 0}
def parse_bet_slip(text):
"""
Parse OCR text to extract bet slip components.
Args:
text: OCR extracted text.
Returns:
List of parsed leg dicts with player, stat_type, line, over_under.
"""
legs = []
lines = text.split('\n')
stat_keywords = {
'pts': 'points', 'points': 'points', 'reb': 'rebounds',
'rebounds': 'rebounds', 'ast': 'assists', 'assists': 'assists',
'threes': 'threes', '3pt': 'threes', '3-pointers': 'threes',
'strikeouts': 'strikeouts', 'ks': 'strikeouts', 'k\'s': 'strikeouts',
'hits': 'hits', 'total bases': 'total_bases', 'tb': 'total_bases',
'rbi': 'rbi', 'home runs': 'home_runs', 'hr': 'home_runs',
'walks': 'walks', 'bb': 'walks'
}
for line in lines:
line_lower = line.lower().strip()
if not line_lower:
continue
# Try to find over/under
over_under = None
if 'over' in line_lower:
over_under = 'over'
elif 'under' in line_lower:
over_under = 'under'
# Try to find stat type
stat_type = None
for keyword, mapped in stat_keywords.items():
if keyword in line_lower:
stat_type = mapped
break
# Try to find line value (number with optional .5)
import re
numbers = re.findall(r'\d+\.?\d*', line)
prop_line = None
for n in numbers:
val = float(n)
if 0.5 <= val <= 99.5:
prop_line = val
break
if stat_type and prop_line and over_under:
# Player name is whatever text precedes the stat keyword
legs.append({
'raw_text': line.strip(),
'stat_type': stat_type,
'line': prop_line,
'over_under': over_under,
'player_name': None # needs fuzzy matching
})
return legs
@image_grade_bp.route('/from-image', methods=['POST'])
def grade_from_image():
"""
Accept bet slip screenshot, OCR it, parse legs, and grade.
Request: multipart/form-data with 'image' file.
Returns:
JSON with parsed legs, OCR confidence, and grades (or confirmation request).
"""
if 'image' not in request.files:
return jsonify({'error': 'No image file provided'}), 400
image_file = request.files['image']
image_bytes = image_file.read()
if len(image_bytes) == 0:
return jsonify({'error': 'Empty image file'}), 400
# Preprocess
processed = preprocess_image(image_bytes)
if processed is None:
return jsonify({'error': 'Image processing failed'}), 500
# OCR
ocr_result = ocr_image(processed)
# Parse
legs = parse_bet_slip(ocr_result['text'])
# Low confidence — ask user to confirm
if ocr_result['confidence'] < 60:
return jsonify({
'status': 'low_confidence',
'ocr_confidence': ocr_result['confidence'],
'extracted_text': ocr_result['text'],
'parsed_legs': legs,
'message': 'OCR confidence is low. Please confirm the extracted information.'
})
return jsonify({
'status': 'parsed',
'ocr_confidence': ocr_result['confidence'],
'legs': legs,
'leg_count': len(legs),
'note': 'Legs parsed. Submit to /api/mlb/grade or /api/nba/grade for grading.'
})
@@ -0,0 +1,710 @@
"""
VYNDR Lineup Intelligence — Multi-source lineup monitoring.
Blueprint providing real-time lineup status by aggregating official APIs,
beat reporter tweets, and backup sources. Tracks reporter accuracy over time
and promotes/demotes trust tiers dynamically.
"""
import logging
import re
from datetime import datetime, date
from flask import Blueprint, request, jsonify
from utils.data_warehouse import fetch_with_cache
from utils.retry import api_call_with_retry
logger = logging.getLogger('vyndr')
lineup_bp = Blueprint('lineup_intelligence', __name__)
# ---------------------------------------------------------------------------
# Source priority configuration
# ---------------------------------------------------------------------------
LINEUP_SOURCES = {
'official_api': {
'priority': 1,
'description': 'Official league API (MLB statsapi, NBA official)',
'badge_on_confirm': 'confirmed',
},
'beat_reporter': {
'priority': 2,
'description': 'Beat reporters and insiders — trust is dynamic',
'badge_on_confirm': 'preliminary',
},
'backup_api': {
'priority': 3,
'description': 'Fallback third-party data feeds',
'badge_on_confirm': 'preliminary',
},
}
# ---------------------------------------------------------------------------
# Reporter trust system
# ---------------------------------------------------------------------------
REPORTER_TRUST_TIERS = {
'unverified': {
'min_tracked': 0,
'accuracy': 0.0,
'badge': 'preliminary',
},
'reliable': {
'min_tracked': 10,
'accuracy': 0.80,
'badge': 'preliminary',
},
'verified': {
'min_tracked': 20,
'accuracy': 0.90,
'badge': 'high_confidence',
},
'authoritative': {
'min_tracked': 30,
'accuracy': 0.95,
'badge': 'confirmed',
},
}
STARTING_TRUST = {
'beat_writer': 'reliable',
'national': 'authoritative',
'insider': 'reliable',
'aggregator': 'unverified',
}
# In-memory reporter tracking — production would persist to Supabase.
_reporter_stats = {}
# In-memory lineup cache keyed by (sport, game_date, game_id).
_lineup_cache = {}
# In-memory reporter-to-line-movement correlation log.
_reporter_line_correlations = []
# ---------------------------------------------------------------------------
# Tweet parsing
# ---------------------------------------------------------------------------
LINEUP_KEYWORDS = {
'confirmed_playing': [
'will play', 'starting', 'in the lineup', 'cleared to play',
'available tonight', 'is a go', 'will start', 'expected to play',
'in tonight', 'active tonight',
],
'scratched': [
'scratched', 'out tonight', 'will not play', 'ruled out',
'sits tonight', 'will miss', 'inactive', 'dnp', 'is out',
'not in lineup', 'held out',
],
'questionable': [
'questionable', 'game-time decision', 'gtd', 'uncertain',
'doubtful', 'may sit', 'TBD', 'monitor', 'day-to-day',
'not certain',
],
}
PAST_TENSE_FILTERS = [
'played', 'started', 'was scratched', 'sat out', 'missed',
'did not play', 'was ruled out', 'was inactive', 'had',
'finished', 'went for', 'scored', 'posted',
]
def parse_reporter_tweet(tweet_text, tweet_date=None):
"""
Parse a reporter tweet for lineup-relevant information.
Filters out past-tense recaps and tweets that do not reference today's
games. Returns a dict with player mentions, detected status, and the
raw keyword match, or None if the tweet is not actionable.
Args:
tweet_text: Raw text content of the tweet.
tweet_date: Date the tweet was posted (datetime.date). Defaults to
today if not provided.
Returns:
dict with keys {status, keywords_matched, raw_text} or None.
"""
if tweet_date is None:
tweet_date = date.today()
if tweet_date != date.today():
logger.debug('Skipping tweet from non-today date: %s', tweet_date)
return None
lower = tweet_text.lower()
# Filter past-tense recaps
for phrase in PAST_TENSE_FILTERS:
if phrase in lower:
logger.debug('Filtered past-tense tweet: %s', tweet_text[:80])
return None
# Detect lineup status keywords
for status, keywords in LINEUP_KEYWORDS.items():
matched = [kw for kw in keywords if kw.lower() in lower]
if matched:
return {
'status': status,
'keywords_matched': matched,
'raw_text': tweet_text,
}
return None
# ---------------------------------------------------------------------------
# Reporter trust management
# ---------------------------------------------------------------------------
def _get_reporter_record(reporter_handle):
"""
Retrieve or initialize the tracking record for a reporter.
Args:
reporter_handle: Twitter/X handle of the reporter.
Returns:
dict with keys {handle, total, correct, tier}.
"""
if reporter_handle not in _reporter_stats:
_reporter_stats[reporter_handle] = {
'handle': reporter_handle,
'total': 0,
'correct': 0,
'tier': 'unverified',
}
return _reporter_stats[reporter_handle]
def update_reporter_trust(reporter_handle, was_correct):
"""
Update a reporter's accuracy tracking and promote/demote their tier.
Called after an official source confirms or contradicts a reporter's
earlier lineup call. Walks through REPORTER_TRUST_TIERS from highest
to lowest and assigns the best tier the reporter qualifies for.
Args:
reporter_handle: Twitter/X handle of the reporter.
was_correct: Boolean — did the official source confirm the call?
Returns:
dict with {handle, tier, accuracy, total}.
"""
record = _get_reporter_record(reporter_handle)
record['total'] += 1
if was_correct:
record['correct'] += 1
accuracy = record['correct'] / record['total'] if record['total'] > 0 else 0.0
# Walk tiers from best to worst, assign the highest that qualifies.
tier_order = ['authoritative', 'verified', 'reliable', 'unverified']
assigned_tier = 'unverified'
for tier_name in tier_order:
tier_def = REPORTER_TRUST_TIERS[tier_name]
if (record['total'] >= tier_def['min_tracked']
and accuracy >= tier_def['accuracy']):
assigned_tier = tier_name
break
record['tier'] = assigned_tier
logger.info(
'Reporter %s updated: tier=%s accuracy=%.2f total=%d',
reporter_handle, assigned_tier, accuracy, record['total'],
)
return {
'handle': reporter_handle,
'tier': assigned_tier,
'accuracy': round(accuracy, 4),
'total': record['total'],
}
def get_reporter_badge(reporter_handle):
"""
Return the display badge for a reporter based on their current trust tier.
The badge maps directly from REPORTER_TRUST_TIERS and controls how the
frontend labels lineup intel sourced from this reporter.
Args:
reporter_handle: Twitter/X handle of the reporter.
Returns:
str badge value (e.g. 'preliminary', 'high_confidence', 'confirmed').
"""
record = _get_reporter_record(reporter_handle)
tier = record.get('tier', 'unverified')
return REPORTER_TRUST_TIERS.get(tier, REPORTER_TRUST_TIERS['unverified'])['badge']
# ---------------------------------------------------------------------------
# Two-stage lineup grading
# ---------------------------------------------------------------------------
def process_lineup_update(game_id, sport, player_name, status, source_type,
reporter_handle=None):
"""
Two-stage lineup grading pipeline.
Stage 1 (beat_reporter / backup_api): Record the update with a
preliminary badge. The confidence depends on the reporter's trust tier.
Stage 2 (official_api): Stamp the update with a confirmed badge and
back-validate any earlier reporter calls for that player/game.
Args:
game_id: Unique identifier for the game.
sport: Sport key (e.g. 'mlb', 'nba').
player_name: Full player name.
status: One of 'confirmed_playing', 'scratched', 'questionable'.
source_type: Key from LINEUP_SOURCES ('official_api', 'beat_reporter',
'backup_api').
reporter_handle: Required when source_type is 'beat_reporter'.
Returns:
dict with the stored lineup entry including badge and timestamp.
"""
cache_key = (sport, game_id, player_name.lower())
now = datetime.utcnow().isoformat()
source_def = LINEUP_SOURCES.get(source_type)
if source_def is None:
logger.error('Unknown source_type: %s', source_type)
return {'error': f'Unknown source_type: {source_type}'}
# Determine badge
if source_type == 'official_api':
badge = 'confirmed'
elif source_type == 'beat_reporter' and reporter_handle:
badge = get_reporter_badge(reporter_handle)
else:
badge = source_def.get('badge_on_confirm', 'preliminary')
entry = {
'game_id': game_id,
'sport': sport,
'player': player_name,
'status': status,
'source': source_type,
'reporter': reporter_handle,
'badge': badge,
'timestamp': now,
}
existing = _lineup_cache.get(cache_key)
# Stage 2: official confirmation — back-validate reporter calls.
if source_type == 'official_api' and existing:
prior_source = existing.get('source')
prior_reporter = existing.get('reporter')
if prior_source == 'beat_reporter' and prior_reporter:
was_correct = existing.get('status') == status
update_reporter_trust(prior_reporter, was_correct)
logger.info(
'Back-validated reporter %s for %s: correct=%s',
prior_reporter, player_name, was_correct,
)
# Only overwrite if the new source has equal or higher priority.
if existing is None or source_def['priority'] <= LINEUP_SOURCES.get(
existing.get('source', ''), {}).get('priority', 99):
_lineup_cache[cache_key] = entry
logger.info(
'Lineup update stored: %s %s -> %s [%s]',
player_name, status, source_type, badge,
)
else:
logger.debug(
'Skipped lower-priority update for %s from %s',
player_name, source_type,
)
return entry
# ---------------------------------------------------------------------------
# PATCH: Scratch → Redistribution → Re-grade → Alt Line → Alert chain
# ---------------------------------------------------------------------------
def handle_scratch_chain(player_name, player_id, team, game_id, sport, badge):
"""
Full chain when a player is confirmed OUT:
1. Trigger redistribution engine for absorption analysis
2. Re-grade affected props with redistribution context
3. Auto-scan alt lines for any A-grade re-grades
4. Format and return alert with all intelligence
Args:
player_name: Scratched player name.
player_id: Scratched player ID.
team: Team identifier.
game_id: Game identifier.
sport: 'nba' or 'mlb'.
badge: Reporter badge level.
Returns:
Dict with redistribution, re-graded props, and alt line opportunities.
"""
result = {
'player_scratched': player_name,
'redistribution': None,
'regraded_props': [],
'alt_opportunities': [],
'alert': None
}
# Step 1: Redistribution
try:
from blueprints.redistribution import calculate_redistribution_internal
redistribution = calculate_redistribution_internal(player_id, game_id)
result['redistribution'] = redistribution
except Exception as e:
logger.warning(f'[VYNDR] Redistribution chain failed: {e}')
redistribution = None
# Step 2: Re-grade affected props (stub — connects to grading engine)
# In production, get_props_affected_by_scratch returns live props
# and recalculate_grade runs the full pipeline with redistribution_context
# Step 3: Alt line scan for A-grade re-grades
try:
from blueprints.odds_scanner import scan_alt_lines_internal
for prop in result.get('regraded_props', []):
if prop.get('grade') in ['A+', 'A', 'A-']:
alt = scan_alt_lines_internal(
sport, prop.get('player', ''),
prop.get('stat_type', ''),
standard_grade=prop
)
if alt.get('recommend_alt'):
result['alt_opportunities'].append(alt)
prop['alt_line_opportunity'] = alt.get('optimal_alt')
except Exception as e:
logger.warning(f'[VYNDR] Alt line chain failed: {e}')
# Step 4: Format alert
if redistribution and redistribution.get('primary_beneficiary'):
primary = redistribution['primary_beneficiary']
alert = (
f"{player_name} is OUT.\n"
f"{primary.get('player_name', '?')} is underpriced. "
f"Boost: +{primary.get('combined_prop_boost', 0):.0%}. "
f"Confidence: {primary.get('confidence', 0):.0%}."
)
if result['alt_opportunities']:
alt = result['alt_opportunities'][0].get('optimal_alt', {})
alert += (
f"\n\nAlt line: {alt.get('over_under', '').upper()} "
f"{alt.get('line', '?')} at {alt.get('odds', '?')} "
f"\u2192 Edge: {alt.get('real_edge', 0):.1%}"
)
result['alert'] = alert
return result
def poll_reporter_feeds(sport):
"""
Poll reporter feeds for lineup updates. Called by GitHub Actions cron.
Args:
sport: 'nba' or 'mlb'.
"""
logger.info(f'[VYNDR] Polling reporter feeds for {sport}')
# In production, fetch from Twitter API / RSS feeds
# Parse via parse_reporter_tweet, process via process_lineup_update
def check_all_lineups(sport):
"""
Check all lineup statuses from official APIs. Called by pre-game cron.
Args:
sport: 'nba' or 'mlb'.
"""
logger.info(f'[VYNDR] Checking all lineups for {sport}')
# In production, fetch from official MLB/NBA APIs
# ---------------------------------------------------------------------------
# Reporter-to-line-movement correlation
# ---------------------------------------------------------------------------
def log_reporter_line_correlation(reporter_handle, game_id, player_name,
tweet_timestamp, line_move_timestamp,
line_before, line_after):
"""
Track the time gap between a reporter's tweet and subsequent book line
movement. Used to measure how quickly the market prices reporter intel.
Args:
reporter_handle: Twitter/X handle.
game_id: Unique game identifier.
player_name: Player referenced in the tweet.
tweet_timestamp: ISO timestamp of the tweet.
line_move_timestamp: ISO timestamp of the detected line move.
line_before: Odds/line value before the move.
line_after: Odds/line value after the move.
Returns:
dict with the correlation record including gap_seconds.
"""
try:
tweet_dt = datetime.fromisoformat(tweet_timestamp)
move_dt = datetime.fromisoformat(line_move_timestamp)
gap_seconds = (move_dt - tweet_dt).total_seconds()
except (ValueError, TypeError) as exc:
logger.warning('Could not compute gap for %s: %s', reporter_handle, exc)
gap_seconds = None
record = {
'reporter': reporter_handle,
'game_id': game_id,
'player': player_name,
'tweet_timestamp': tweet_timestamp,
'line_move_timestamp': line_move_timestamp,
'line_before': line_before,
'line_after': line_after,
'gap_seconds': gap_seconds,
}
_reporter_line_correlations.append(record)
logger.info(
'Line correlation logged: reporter=%s player=%s gap=%.1fs line %s->%s',
reporter_handle, player_name,
gap_seconds if gap_seconds is not None else -1,
line_before, line_after,
)
return record
# ---------------------------------------------------------------------------
# MLB lineup parsing via statsapi
# ---------------------------------------------------------------------------
def get_mlb_lineups_today(game_date=None):
"""
Fetch today's MLB starting lineups from the official statsapi.
Uses utils.retry for resilience and utils.data_warehouse for caching
(15-minute TTL since lineups can change close to game time).
Args:
game_date: Date string in 'YYYY-MM-DD' format. Defaults to today.
Returns:
list of dicts, one per game, each containing home/away lineup arrays.
"""
if game_date is None:
game_date = date.today().strftime('%Y-%m-%d')
cache_key = f'mlb_lineups_{game_date}'
def _fetch():
"""Inner fetch wrapped for retry and caching."""
try:
import statsapi
except ImportError:
logger.error('statsapi not installed — cannot fetch MLB lineups')
return []
schedule = api_call_with_retry(
lambda: statsapi.schedule(date=game_date),
max_retries=3,
label='statsapi.schedule',
)
if not schedule:
logger.warning('No MLB games found for %s', game_date)
return []
games = []
for game in schedule:
game_id = game.get('game_id')
if game_id is None:
continue
try:
boxscore = api_call_with_retry(
lambda gid=game_id: statsapi.boxscore_data(gid),
max_retries=3,
label='statsapi.boxscore_data',
)
except Exception as exc:
logger.warning('Failed to get boxscore for game %s: %s', game_id, exc)
continue
home_lineup = []
away_lineup = []
for side, lineup_list in [('home', home_lineup), ('away', away_lineup)]:
batters_key = f'{side}Batters'
batters = boxscore.get(batters_key, [])
for batter in batters:
if isinstance(batter, dict):
name = batter.get('name', batter.get('namefield', ''))
if name:
lineup_list.append({
'name': name.strip(),
'position': batter.get('position', ''),
'batting_order': batter.get('battingOrder', ''),
})
games.append({
'game_id': game_id,
'home_team': game.get('home_name', ''),
'away_team': game.get('away_name', ''),
'game_time': game.get('game_datetime', ''),
'status': game.get('status', ''),
'home_lineup': home_lineup,
'away_lineup': away_lineup,
})
logger.info('Fetched %d MLB game lineups for %s', len(games), game_date)
return games
return fetch_with_cache(cache_key, _fetch, ttl=900)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@lineup_bp.route('/status/<sport>/<game_date>', methods=['GET'])
def lineup_status(sport, game_date):
"""
Return lineup status for all tracked games in a sport on a given date.
Pulls from the in-memory lineup cache and, for MLB, supplements with
official statsapi data. Results include the confidence badge for each
player entry.
Args:
sport: Sport key ('mlb', 'nba', 'nfl', etc.).
game_date: Date string 'YYYY-MM-DD'.
Returns:
JSON response with lineup entries grouped by game.
"""
try:
target_date = datetime.strptime(game_date, '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'Invalid date format. Use YYYY-MM-DD.'}), 400
# Gather cached entries for the sport/date
entries = []
for (cached_sport, cached_game, _player), entry in _lineup_cache.items():
if cached_sport == sport:
entries.append(entry)
# For MLB, supplement with official lineups if available
if sport == 'mlb':
try:
official_lineups = get_mlb_lineups_today(game_date)
for game in official_lineups:
for side in ['home_lineup', 'away_lineup']:
for player in game.get(side, []):
process_lineup_update(
game_id=str(game['game_id']),
sport='mlb',
player_name=player['name'],
status='confirmed_playing',
source_type='official_api',
)
except Exception as exc:
logger.warning('MLB official lineup fetch failed: %s', exc)
# Re-gather after potential official update
result = {}
for (cached_sport, cached_game, _player), entry in _lineup_cache.items():
if cached_sport == sport:
result.setdefault(cached_game, []).append(entry)
return jsonify({
'sport': sport,
'date': game_date,
'games': result,
'total_entries': sum(len(v) for v in result.values()),
})
@lineup_bp.route('/reporter-update', methods=['POST'])
def reporter_update():
"""
Process a reporter tweet and store the lineup update.
Expects JSON body:
{
"reporter_handle": "@handle",
"reporter_type": "beat_writer" | "national" | "insider" | "aggregator",
"tweet_text": "Player X will play tonight...",
"tweet_date": "YYYY-MM-DD" (optional, defaults to today),
"game_id": "game_123",
"sport": "mlb",
"player_name": "Player X"
}
Returns:
JSON with the parsed tweet result and stored lineup entry, or an
error if the tweet was filtered or unparseable.
"""
data = request.get_json(silent=True)
if not data:
return jsonify({'error': 'Request body must be JSON.'}), 400
required = ['reporter_handle', 'tweet_text', 'game_id', 'sport', 'player_name']
missing = [f for f in required if f not in data]
if missing:
return jsonify({'error': f'Missing required fields: {missing}'}), 400
reporter_handle = data['reporter_handle']
reporter_type = data.get('reporter_type', 'aggregator')
tweet_text = data['tweet_text']
game_id = data['game_id']
sport = data['sport']
player_name = data['player_name']
# Parse tweet date
tweet_date = None
if data.get('tweet_date'):
try:
tweet_date = datetime.strptime(data['tweet_date'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'Invalid tweet_date format. Use YYYY-MM-DD.'}), 400
# Initialize reporter trust if first time seeing them
record = _get_reporter_record(reporter_handle)
if record['total'] == 0 and reporter_type in STARTING_TRUST:
record['tier'] = STARTING_TRUST[reporter_type]
# Parse the tweet
parsed = parse_reporter_tweet(tweet_text, tweet_date=tweet_date)
if parsed is None:
return jsonify({
'filtered': True,
'reason': 'Tweet filtered (past tense, non-today, or no lineup keywords).',
}), 200
# Store lineup update
entry = process_lineup_update(
game_id=game_id,
sport=sport,
player_name=player_name,
status=parsed['status'],
source_type='beat_reporter',
reporter_handle=reporter_handle,
)
return jsonify({
'filtered': False,
'parsed': parsed,
'lineup_entry': entry,
'reporter_badge': get_reporter_badge(reporter_handle),
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,468 @@
"""
VYNDR NBA Context Service
Teammate impact, game script, home/road splits, rest/travel, matchup pace,
foul trouble risk, B2B adjustments, positional matchup defense,
usage-efficiency tradeoff. NBA sub-scores endpoint.
"""
import time
import json
import os
import logging
from flask import Blueprint, request, jsonify
from utils.data_warehouse import fetch_with_cache
from utils.archetypes import (
NBA_DIMENSIONS, DEFAULT_NBA_WEIGHTS, NBA_SUB_SCORES,
get_archetype_scores, blend_archetype_weights
)
logger = logging.getLogger('vyndr')
nba_context_bp = Blueprint('nba_context', __name__)
NBA_API_DELAY = 0.6
# --- Teammate Impact ---
TEAMMATE_IMPACT_RULES = {
'primary_ball_handler_out': {
'remaining_playmaker': {'base_usage_boost': 0.04, 'assist_boost': 1.5},
'remaining_scorers': {'base_usage_boost': 0.02, 'fg_attempts_boost': 1.8}
},
'primary_scorer_out': {
'secondary_scorers': {'base_usage_boost': 0.05, 'fg_attempts_boost': 2.5},
'playmaker': {'assist_reduction': -0.8}
},
'starting_big_out': {
'backup_big': {'minutes_boost': 12, 'rebound_boost': 3.0},
'remaining_bigs': {'rebound_boost': 1.5}
}
}
def calculate_dynamic_usage_boost(out_player_archetype, beneficiary_profile, base_boost):
"""
Scale usage boost by beneficiary's headroom. Player at 20% usage has
more room to absorb than player at 35%.
Args:
out_player_archetype: Archetype of the absent player.
beneficiary_profile: Dict with usage_rate for the beneficiary.
base_boost: Base usage boost from TEAMMATE_IMPACT_RULES.
Returns:
Float — scaled usage boost.
"""
usage_ceiling = 0.38
current_usage = beneficiary_profile.get('usage_rate', 0.20)
headroom = max(0, usage_ceiling - current_usage)
headroom_factor = min(1.0, headroom / 0.15)
return round(base_boost * headroom_factor, 3)
def adjust_for_usage_efficiency_tradeoff(usage_boost, player_profile):
"""
Higher usage often means lower efficiency. ~-1.5% TS per +5% usage increase.
Without this, model overestimates beneficiaries of teammate absences.
Args:
usage_boost: Float usage increase.
player_profile: Dict with player stats.
Returns:
Dict with volume_boost, efficiency_penalty, net_effect.
"""
ts_penalty_per_5pct_usage = -0.015
projected_ts_change = usage_boost * (ts_penalty_per_5pct_usage / 0.05)
return {
'volume_boost': usage_boost,
'efficiency_penalty': projected_ts_change,
'net_effect': usage_boost + projected_ts_change
}
# --- Game Script ---
def adjust_minutes_for_spread(projected_minutes, spread, is_favorite):
"""
Adjust projected minutes based on game spread (blowout risk).
Args:
projected_minutes: Base projected minutes.
spread: Point spread (positive number).
is_favorite: Whether the player's team is favored.
Returns:
Adjusted projected minutes.
"""
if abs(spread) >= 12:
return projected_minutes * (0.92 if is_favorite else 0.95)
elif abs(spread) >= 8 and is_favorite:
return projected_minutes * 0.96
return projected_minutes
# --- Home/Road Splits ---
def calculate_home_road_adjustment(player_splits, stat_type, is_home_game):
"""
Apply home/road split as context adjustment. Only when >5% difference.
Args:
player_splits: Dict with {stat_type}_home and {stat_type}_road keys.
stat_type: Stat type string.
is_home_game: Boolean.
Returns:
Float adjustment to projected value.
"""
home_avg = player_splits.get(f'{stat_type}_home')
road_avg = player_splits.get(f'{stat_type}_road')
if home_avg is None or road_avg is None:
return 0.0
overall_avg = (home_avg + road_avg) / 2
if overall_avg == 0:
return 0.0
if abs(home_avg - road_avg) / overall_avg < 0.05:
return 0.0
return (home_avg if is_home_game else road_avg) - overall_avg
# --- Rest + Travel Fatigue ---
REST_TRAVEL_ADJUSTMENT = {
'same_timezone': 0.0,
'one_tz_change': -0.01,
'two_tz_change': -0.02,
'three_tz_change': -0.03
}
def calculate_travel_fatigue(prev_game_tz_offset, current_tz_offset):
"""
Account for travel distance, not just rest days.
BOS→LAL on a B2B is worse than BOS→NYK on a B2B.
Args:
prev_game_tz_offset: UTC offset of previous game arena.
current_tz_offset: UTC offset of current game arena.
Returns:
Float adjustment (negative = fatigue penalty).
"""
if prev_game_tz_offset is None or current_tz_offset is None:
return 0.0
tz_diff = abs(current_tz_offset - prev_game_tz_offset)
if tz_diff == 0:
return REST_TRAVEL_ADJUSTMENT['same_timezone']
elif tz_diff == 1:
return REST_TRAVEL_ADJUSTMENT['one_tz_change']
elif tz_diff == 2:
return REST_TRAVEL_ADJUSTMENT['two_tz_change']
else:
return REST_TRAVEL_ADJUSTMENT['three_tz_change']
# --- Matchup-Specific Pace ---
def calculate_matchup_pace(team_a_pace, team_b_pace, league_avg_pace, is_home):
"""
Matchup-specific pace — not just team averages.
Two fast teams play FASTER than either team's average.
Home team pace weighs 60/40.
Args:
team_a_pace: Pace of the player's team.
team_b_pace: Pace of the opponent.
league_avg_pace: League average pace.
is_home: Whether the player's team is home.
Returns:
Float factor relative to league average (>1.0 = faster).
"""
if league_avg_pace <= 0:
return 1.0
if is_home:
raw_pace = (team_a_pace * 0.60 + team_b_pace * 0.40)
else:
raw_pace = (team_a_pace * 0.40 + team_b_pace * 0.60)
return raw_pace / league_avg_pace
# --- Foul Trouble Risk ---
def foul_trouble_risk(fouls_per_game):
"""
Foul-prone players have wider minutes variance.
Doesn't change the mean — widens the distribution.
Args:
fouls_per_game: Season average fouls per game.
Returns:
Dict with minutes_std_boost.
"""
if fouls_per_game >= 3.5:
return {'minutes_std_boost': 3.0}
elif fouls_per_game >= 2.8:
return {'minutes_std_boost': 1.5}
return {'minutes_std_boost': 0.0}
# --- Stat-Specific B2B Adjustments ---
B2B_ADJUSTMENTS = {
'points': -0.04,
'rebounds': 0.02,
'assists': -0.01,
'threes': -0.03,
'pts_reb_ast': -0.02,
'default': -0.02
}
def apply_b2b_adjustment(projection, stat_type, is_b2b_second_game):
"""
B2B fatigue is NOT linear across stats.
Points and threes drop. Rebounds actually increase.
Args:
projection: Base projected value.
stat_type: Stat type string.
is_b2b_second_game: Whether this is the second game of a B2B.
Returns:
Adjusted projection.
"""
if not is_b2b_second_game:
return projection
adj = B2B_ADJUSTMENTS.get(stat_type, B2B_ADJUSTMENTS['default'])
return projection * (1 + adj)
# --- Positional Matchup Defense ---
def calculate_positional_matchup(position_defenders, team_defensive_rating):
"""
Position-specific defensive quality, not just team rating.
When tracking data available, use who actually guarded whom (positionless basketball).
Args:
position_defenders: List of defender dicts with 'defensive_rating' and 'minutes'.
team_defensive_rating: Fallback team-level defensive rating.
Returns:
Float defensive rating for this matchup.
"""
if not position_defenders:
return team_defensive_rating
weighted_def = sum(
p.get('defensive_rating', team_defensive_rating) * p.get('minutes', 20)
for p in position_defenders
)
total_min = sum(p.get('minutes', 20) for p in position_defenders)
return weighted_def / total_min if total_min > 0 else team_defensive_rating
# --- Playoff Modifiers ---
PLAYOFF_MODIFIERS = {
'starter_minutes_boost': 1.10,
'bench_dnp_threshold': 8,
'primary_scorer_fg_penalty': 0.96,
'primary_scorer_fta_boost': 1.10,
'elimination_star_pts_boost': 1.05,
'elimination_star_min_boost': 1.08,
'rest_1_day': 'fatigue',
'rest_4_plus_days': 'rust_flag'
}
def apply_playoff_modifiers(projection, stat_type, game_context, player_profile):
"""
Apply playoff-specific modifiers to projection.
Args:
projection: Base projected value.
stat_type: Stat type string.
game_context: Dict with playoff info (is_elimination, is_home, etc.).
player_profile: Dict with player stats.
Returns:
Modified projection.
"""
if not game_context.get('is_playoff'):
return projection
# Starters get more minutes
if player_profile.get('is_starter'):
projection *= PLAYOFF_MODIFIERS['starter_minutes_boost']
# Elimination game — star players elevate
if game_context.get('is_elimination') and player_profile.get('usage_rate', 0) > 0.25:
if stat_type == 'points':
projection *= PLAYOFF_MODIFIERS['elimination_star_pts_boost']
return projection
# --- NBA Sub-Scores Endpoint ---
@nba_context_bp.route('/sub-scores/<player_id>/<game_id>', methods=['GET'])
def get_nba_sub_scores(player_id, game_id):
"""
Calculate all NBA sub-scores for a player in a specific game context.
Returns individual sub-scores that the Node.js engine weights via archetypes.
Args:
player_id: NBA player ID.
game_id: NBA game ID.
Returns:
JSON with sub_scores, archetype_scores, and blended_weights.
"""
stat_type = request.args.get('stat_type', 'points')
is_home = request.args.get('is_home', 'true').lower() == 'true'
# Build player profile (would come from nba_api in production)
player_profile = _get_player_profile_cached(player_id)
game_context = _get_game_context_cached(game_id)
# Calculate each sub-score
recent_form = _calculate_recent_form(player_id, stat_type)
matchup_defense = _calculate_matchup_defense_score(player_id, game_context)
pace_factor = _calculate_pace_score(player_profile, game_context, is_home)
usage_context = _calculate_usage_score(player_id, game_context)
home_road = calculate_home_road_adjustment(
player_profile.get('splits', {}), stat_type, is_home
)
rest_travel_score = _calculate_rest_travel_score(player_profile, game_context)
sub_scores = {
'recent_form': round(recent_form, 3),
'matchup_defense': round(matchup_defense, 3),
'pace_factor': round(pace_factor, 3),
'usage_context': round(usage_context, 3),
'home_road': round(home_road, 3),
'rest_travel': round(rest_travel_score, 3)
}
archetype_scores = get_archetype_scores(player_profile, NBA_DIMENSIONS)
blended_weights = blend_archetype_weights(player_profile, NBA_DIMENSIONS, DEFAULT_NBA_WEIGHTS)
return jsonify({
'player_id': player_id,
'game_id': game_id,
'stat_type': stat_type,
'sub_scores': sub_scores,
'archetype_scores': {k: round(v, 3) for k, v in archetype_scores.items()},
'blended_weights': {k: round(v, 3) for k, v in blended_weights.items()}
})
@nba_context_bp.route('/teammate-impact/<player_id>/<game_id>', methods=['GET'])
def get_teammate_impact(player_id, game_id):
"""Get teammate impact for a player given tonight's injury report."""
return jsonify({
'player_id': player_id,
'game_id': game_id,
'impact': {},
'note': 'Requires live injury report data'
})
@nba_context_bp.route('/game-script/<game_id>', methods=['GET'])
def get_game_script(game_id):
"""Get game script projections from spread."""
return jsonify({
'game_id': game_id,
'spread': None,
'minutes_adjustments': {},
'note': 'Requires odds data'
})
@nba_context_bp.route('/rest-travel/<player_id>/<game_id>', methods=['GET'])
def get_rest_travel(player_id, game_id):
"""Get rest and travel fatigue for a player."""
return jsonify({
'player_id': player_id,
'game_id': game_id,
'rest_days': None,
'travel_fatigue_adj': 0.0,
'note': 'Requires schedule data'
})
# --- Internal Helpers ---
def _get_player_profile_cached(player_id):
"""Get or build player profile from cache/API."""
cached = fetch_with_cache(
f'nba_profile_{player_id}',
lambda: _fetch_player_profile(player_id),
data_type='player_stats'
)
return cached or {}
def _fetch_player_profile(player_id):
"""Fetch player profile from nba_api."""
time.sleep(NBA_API_DELAY)
try:
from nba_api.stats.endpoints import CommonPlayerInfo
info = CommonPlayerInfo(player_id=player_id)
df = info.get_data_frames()[0]
if df.empty:
return {}
row = df.iloc[0]
return {
'player_id': player_id,
'name': row.get('DISPLAY_FIRST_LAST', ''),
'team_id': str(row.get('TEAM_ID', '')),
'position': row.get('POSITION', ''),
'usage_rate': 0.20, # populated from team stats
'assist_rate': 0.15,
'three_pa_rate': 0.30,
'fg_pct': 0.45,
'reb_per_game': 4.0,
'splits': {}
}
except Exception as e:
logger.warning(f'[VYNDR] Player profile fetch failed: {e}')
return {}
def _get_game_context_cached(game_id):
"""Get game context from cache."""
return fetch_with_cache(
f'nba_game_{game_id}',
lambda: {'game_id': game_id},
data_type='player_stats'
) or {}
def _calculate_recent_form(player_id, stat_type):
"""Calculate recent form score (0.0-1.0)."""
return 0.50 # Neutral default; populated with real data via nba_api
def _calculate_matchup_defense_score(player_id, game_context):
"""Calculate matchup defense score (0.0-1.0)."""
return 0.50
def _calculate_pace_score(player_profile, game_context, is_home):
"""Calculate pace factor score."""
return 0.50
def _calculate_usage_score(player_id, game_context):
"""Calculate usage context score."""
return 0.50
def _calculate_rest_travel_score(player_profile, game_context):
"""Calculate rest/travel fatigue score."""
return 0.0
@@ -0,0 +1,712 @@
"""
VYNDR Odds Scanner — Blueprint
Fetches, parses, stores, and analyzes odds from The Odds API.
Manages scan scheduling, line movement detection, and full-slate grading.
"""
import os
import logging
from datetime import datetime, timezone
import requests
from flask import Blueprint, request, jsonify
from utils.data_warehouse import (
store_odds_batch,
fetch_odds_by_date,
fetch_odds_by_scan_type,
)
from utils.retry import retry_with_backoff
from utils.edge_calculator import calculate_real_edge, grade_edge
logger = logging.getLogger(__name__)
odds_bp = Blueprint('odds_scanner', __name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports'
ODDS_API_KEY = os.environ.get('ODDS_API_KEY')
SPORT_KEYS = {
'nba': 'basketball_nba',
'mlb': 'baseball_mlb',
}
# Free-tier scan strategy — maximizes coverage on 2 pulls/day
ODDS_SCAN_STRATEGY = {
'morning_scan': '10:00 AM ET',
'pre_game_scan': '90min before first game',
'max_daily_pulls': 2,
'priority': 'games_with_confirmed_lineups_first',
'market_priority': [
'player_points',
'player_rebounds',
'player_assists',
'player_threes',
'player_points_rebounds_assists',
'player_strikeouts',
'player_hits',
'player_total_bases',
],
}
# ---------------------------------------------------------------------------
# Core functions
# ---------------------------------------------------------------------------
@retry_with_backoff(max_retries=3, base_delay=2.0)
def fetch_player_props(sport, scan_type='morning_open'):
"""
Fetch player prop odds from The Odds API for a given sport.
Pulls markets based on ODDS_SCAN_STRATEGY priority, parses them into
a flat prop list, and stores the batch in the odds warehouse.
Args:
sport: Sport key (e.g. 'nba', 'mlb').
scan_type: One of 'morning_open' or 'pre_game'.
Returns:
dict with 'props_stored' count and 'api_requests_remaining'.
Raises:
ValueError: If sport is not supported or API key is missing.
requests.exceptions.RequestException: On network failures (retried).
"""
if not ODDS_API_KEY:
raise ValueError('ODDS_API_KEY environment variable is not set')
sport_key = SPORT_KEYS.get(sport)
if not sport_key:
raise ValueError(f'Unsupported sport: {sport}. Supported: {list(SPORT_KEYS.keys())}')
markets = ','.join(ODDS_SCAN_STRATEGY['market_priority'])
url = f'{ODDS_API_BASE}/{sport_key}/odds'
params = {
'apiKey': ODDS_API_KEY,
'regions': 'us',
'markets': markets,
'oddsFormat': 'american',
}
logger.info('Fetching props for %s (scan_type=%s)', sport, scan_type)
response = requests.get(url, params=params, timeout=30)
response.raise_for_status()
api_requests_remaining = response.headers.get('x-requests-remaining', 'unknown')
logger.info('API requests remaining: %s', api_requests_remaining)
raw_games = response.json()
props = parse_odds_response(raw_games, sport)
stored_count = store_in_odds_warehouse(props, sport, scan_type)
return {
'props_stored': stored_count,
'api_requests_remaining': api_requests_remaining,
}
def parse_odds_response(response, sport):
"""
Parse raw Odds API response into a flat list of prop dicts.
Walks the bookmaker -> market -> outcome hierarchy and normalizes
each outcome into a consistent shape for downstream analysis.
Args:
response: List of game objects from The Odds API.
sport: Sport key for tagging.
Returns:
List of dicts, each representing a single prop line:
{
'game_id', 'home_team', 'away_team', 'commence_time',
'bookmaker', 'market', 'player', 'line', 'over_price',
'under_price', 'sport', 'fetched_at'
}
"""
props = []
fetched_at = datetime.now(timezone.utc).isoformat()
for game in response:
game_id = game.get('id')
home_team = game.get('home_team')
away_team = game.get('away_team')
commence_time = game.get('commence_time')
for bookmaker in game.get('bookmakers', []):
bookmaker_key = bookmaker.get('key')
for market in bookmaker.get('markets', []):
market_key = market.get('key')
outcomes = market.get('outcomes', [])
# Outcomes come in Over/Under pairs — group by player + line
outcome_map = {}
for outcome in outcomes:
player = outcome.get('description', 'unknown')
point = outcome.get('point')
side = outcome.get('name', '').lower() # 'over' or 'under'
price = outcome.get('price')
key = (player, point)
if key not in outcome_map:
outcome_map[key] = {
'player': player,
'line': point,
'over_price': None,
'under_price': None,
}
if side == 'over':
outcome_map[key]['over_price'] = price
elif side == 'under':
outcome_map[key]['under_price'] = price
for (player, line), data in outcome_map.items():
props.append({
'game_id': game_id,
'home_team': home_team,
'away_team': away_team,
'commence_time': commence_time,
'bookmaker': bookmaker_key,
'market': market_key,
'player': data['player'],
'line': data['line'],
'over_price': data['over_price'],
'under_price': data['under_price'],
'sport': sport,
'fetched_at': fetched_at,
})
logger.info('Parsed %d props from %d games', len(props), len(response))
return props
def store_in_odds_warehouse(props, sport, scan_type):
"""
Persist parsed props to the Supabase odds_warehouse table.
Each row is tagged with sport, scan_type, and insertion timestamp
to enable historical comparison and line movement detection.
Args:
props: List of prop dicts from parse_odds_response.
sport: Sport key.
scan_type: 'morning_open' or 'pre_game'.
Returns:
int — number of rows stored.
"""
if not props:
logger.warning('No props to store for %s (%s)', sport, scan_type)
return 0
rows = []
for prop in props:
rows.append({
**prop,
'scan_type': scan_type,
'stored_at': datetime.now(timezone.utc).isoformat(),
})
try:
result = store_odds_batch(rows)
stored = result.get('count', len(rows))
logger.info('Stored %d props to odds_warehouse (%s / %s)', stored, sport, scan_type)
return stored
except Exception:
logger.exception('Failed to store props for %s (%s)', sport, scan_type)
raise
def detect_line_movements(sport, threshold=0.5):
"""
Compare morning_open vs pre_game scans and flag significant line moves.
A movement exceeding the threshold triggers a regrade of the affected
prop, since the edge calculation may have shifted.
Args:
sport: Sport key.
threshold: Minimum absolute line change to flag (default 0.5).
Returns:
List of dicts describing each significant movement:
{
'player', 'market', 'game_id',
'morning_line', 'pregame_line', 'movement',
'morning_over', 'pregame_over', 'price_shift',
'regrade_triggered'
}
"""
try:
morning_props = fetch_odds_by_scan_type(sport, 'morning_open')
pregame_props = fetch_odds_by_scan_type(sport, 'pre_game')
except Exception:
logger.exception('Failed to fetch scans for movement detection (%s)', sport)
raise
# Index morning props by (player, market, game_id) for fast lookup
morning_index = {}
for prop in morning_props:
key = (prop['player'], prop['market'], prop['game_id'])
morning_index[key] = prop
movements = []
for prop in pregame_props:
key = (prop['player'], prop['market'], prop['game_id'])
morning = morning_index.get(key)
if not morning:
continue
morning_line = morning.get('line') or 0
pregame_line = prop.get('line') or 0
line_movement = abs(pregame_line - morning_line)
morning_over = morning.get('over_price') or 0
pregame_over = prop.get('over_price') or 0
price_shift = pregame_over - morning_over
if line_movement >= threshold:
regrade_triggered = True
logger.info(
'Line movement detected: %s %s%.1f -> %.1f (delta %.1f)',
prop['player'], prop['market'], morning_line, pregame_line, line_movement,
)
else:
regrade_triggered = False
if line_movement >= threshold or abs(price_shift) >= 15:
movements.append({
'player': prop['player'],
'market': prop['market'],
'game_id': prop['game_id'],
'morning_line': morning_line,
'pregame_line': pregame_line,
'movement': round(pregame_line - morning_line, 2),
'morning_over': morning_over,
'pregame_over': pregame_over,
'price_shift': price_shift,
'regrade_triggered': regrade_triggered,
})
logger.info('Detected %d significant movements for %s', len(movements), sport)
return movements
def scan_full_slate(sport):
"""
Grade every prop on tonight's slate and return ranked results.
Fetches the latest pre_game scan (or morning_open if pre_game is
unavailable), calculates real edge for each prop, assigns a letter
grade, and sorts by descending edge. The capper account only posts
plays graded A- and above.
Args:
sport: Sport key.
Returns:
dict with 'total_props', 'postable_plays' (A- and above),
and 'full_slate' (all graded props sorted by edge).
"""
try:
props = fetch_odds_by_scan_type(sport, 'pre_game')
if not props:
props = fetch_odds_by_scan_type(sport, 'morning_open')
except Exception:
logger.exception('Failed to fetch props for full slate scan (%s)', sport)
raise
if not props:
return {'total_props': 0, 'postable_plays': [], 'full_slate': []}
graded = []
for prop in props:
try:
edge_result = calculate_real_edge(prop)
real_edge = edge_result.get('edge', 0)
grade = grade_edge(real_edge)
graded.append({
'player': prop.get('player'),
'market': prop.get('market'),
'game_id': prop.get('game_id'),
'home_team': prop.get('home_team'),
'away_team': prop.get('away_team'),
'line': prop.get('line'),
'over_price': prop.get('over_price'),
'under_price': prop.get('under_price'),
'bookmaker': prop.get('bookmaker'),
'real_edge': round(real_edge, 4),
'grade': grade,
})
except Exception:
logger.warning('Failed to grade prop: %s %s', prop.get('player'), prop.get('market'))
continue
# Sort by real edge descending
graded.sort(key=lambda p: p['real_edge'], reverse=True)
# Capper account only posts A- and above
postable_grades = {'A+', 'A', 'A-'}
postable = [p for p in graded if p['grade'] in postable_grades]
logger.info(
'Slate scan complete for %s: %d total, %d postable',
sport, len(graded), len(postable),
)
return {
'total_props': len(graded),
'postable_plays': postable,
'full_slate': graded,
}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@odds_bp.route('/scan/<sport>', methods=['GET'])
def route_scan_slate(sport):
"""
GET /scan/<sport>
Scan the full slate for a sport. Returns graded props ranked by edge.
Query params:
fetch (bool): If true, fetch fresh odds before scanning. Default false.
"""
try:
if sport not in SPORT_KEYS:
return jsonify({'error': f'Unsupported sport: {sport}'}), 400
fetch_fresh = request.args.get('fetch', 'false').lower() == 'true'
scan_type = request.args.get('scan_type', 'morning_open')
if fetch_fresh:
fetch_result = fetch_player_props(sport, scan_type=scan_type)
logger.info('Fresh fetch completed: %s', fetch_result)
result = scan_full_slate(sport)
return jsonify(result), 200
except ValueError as e:
return jsonify({'error': str(e)}), 400
except requests.exceptions.RequestException as e:
logger.exception('Odds API request failed')
return jsonify({'error': 'Odds API request failed', 'detail': str(e)}), 502
except Exception as e:
logger.exception('Unexpected error in scan route')
return jsonify({'error': 'Internal server error'}), 500
@odds_bp.route('/movements/<sport>', methods=['GET'])
def route_line_movements(sport):
"""
GET /movements/<sport>
Compare morning vs pre-game scans and return significant line movements.
Query params:
threshold (float): Minimum line change to flag. Default 0.5.
"""
try:
if sport not in SPORT_KEYS:
return jsonify({'error': f'Unsupported sport: {sport}'}), 400
threshold = float(request.args.get('threshold', 0.5))
movements = detect_line_movements(sport, threshold=threshold)
return jsonify({
'sport': sport,
'threshold': threshold,
'count': len(movements),
'movements': movements,
}), 200
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.exception('Unexpected error in movements route')
return jsonify({'error': 'Internal server error'}), 500
@odds_bp.route('/warehouse/<sport>/<game_date>', methods=['GET'])
def route_warehouse_lookup(sport, game_date):
"""
GET /warehouse/<sport>/<game_date>
Retrieve stored odds from the warehouse for a given sport and date.
game_date format: YYYY-MM-DD
Query params:
scan_type (str): Filter by scan type. Optional.
market (str): Filter by market key. Optional.
"""
try:
if sport not in SPORT_KEYS:
return jsonify({'error': f'Unsupported sport: {sport}'}), 400
# Validate date format
try:
datetime.strptime(game_date, '%Y-%m-%d')
except ValueError:
return jsonify({'error': 'Invalid date format. Use YYYY-MM-DD.'}), 400
scan_type = request.args.get('scan_type')
market = request.args.get('market')
props = fetch_odds_by_date(sport, game_date)
# Apply optional filters
if scan_type:
props = [p for p in props if p.get('scan_type') == scan_type]
if market:
props = [p for p in props if p.get('market') == market]
return jsonify({
'sport': sport,
'game_date': game_date,
'count': len(props),
'props': props,
}), 200
except Exception as e:
logger.exception('Unexpected error in warehouse route')
return jsonify({'error': 'Internal server error'}), 500
# ============================================================
# SUPPLEMENT: Alt Line Scanner
# ============================================================
ALT_LINE_EDGE_IMPROVEMENT_THRESHOLD = 0.03 # 3% edge improvement minimum
ALT_LINE_MODE = os.environ.get('ALT_LINE_MODE', 'manual')
# 'api' = pull from Odds API (requires paid tier with alt markets)
# 'manual' = generate probability ladder at common alt line intervals
def scan_alt_lines_internal(sport, player_name, stat_type, standard_grade=None):
"""
Scan alt lines for a single prop. Finds the alt line with the best
edge-to-odds ratio. Only recommends if edge exceeds standard by 3%+.
Args:
sport: 'nba' or 'mlb'.
player_name: Player name string.
stat_type: Stat type string.
standard_grade: Optional pre-fetched grade result dict.
Returns:
Dict with eligible, optimal_alt, recommend_alt, all_positive_ev_alts.
"""
if not standard_grade:
return {'eligible': False, 'reason': 'No standard grade provided'}
if standard_grade.get('grade') not in ['A+', 'A', 'A-']:
return {'eligible': False, 'reason': 'Only runs on A-grade props'}
model_projection = standard_grade.get('projected_value', 0)
model_std = standard_grade.get('projected_std', 1)
standard_edge = standard_grade.get('real_edge', {}).get('real_edge', 0)
# Get alt lines from odds warehouse
alt_lines = _get_alt_lines_from_warehouse(player_name, stat_type, sport)
if not alt_lines:
return {'eligible': True, 'alt_lines': [], 'reason': 'No alt lines available'}
from utils.bayesian import norm_cdf
from utils.edge_calculator import calculate_real_edge, kelly_criterion
scored_alts = []
for alt in alt_lines:
alt_line = alt.get('line')
alt_odds = alt.get('price', -110)
over_under = alt.get('over_under', 'over')
if alt_line is None or alt_odds is None:
continue
# Calculate model probability at this alt line
if over_under == 'over':
model_prob = 1 - norm_cdf(alt_line, model_projection, model_std)
else:
model_prob = norm_cdf(alt_line, model_projection, model_std)
# Calculate real edge with vig
edge = calculate_real_edge(model_prob, alt_odds)
kelly = kelly_criterion(model_prob, alt_odds)
if edge['is_positive_ev']:
scored_alts.append({
'line': alt_line,
'odds': alt_odds,
'over_under': over_under,
'model_probability': round(model_prob, 3),
'implied_probability': edge['implied_probability'],
'real_edge': edge['real_edge'],
'ev_per_dollar': edge['ev_per_dollar'],
'kelly': kelly,
'bookmaker': alt.get('bookmaker'),
'edge_vs_standard': round(edge['real_edge'] - standard_edge, 3)
})
scored_alts.sort(key=lambda x: x['ev_per_dollar'], reverse=True)
optimal = scored_alts[0] if scored_alts else None
recommend = (optimal is not None and
optimal['edge_vs_standard'] >= ALT_LINE_EDGE_IMPROVEMENT_THRESHOLD)
return {
'eligible': True,
'player': player_name,
'stat_type': stat_type,
'standard_grade': standard_grade.get('grade'),
'standard_edge': standard_edge,
'alt_lines_found': len(scored_alts),
'optimal_alt': optimal,
'recommend_alt': recommend,
'all_positive_ev_alts': scored_alts[:5]
}
def auto_scan_alt_lines_for_a_grades(sport, a_grades=None):
"""
Called after slate scan. For every A-grade prop,
automatically find the best alt line opportunity.
Args:
sport: 'nba' or 'mlb'.
a_grades: Optional list of A-grade result dicts.
Returns:
List of alt line opportunity dicts where alt is recommended.
"""
if not a_grades:
return []
alt_opportunities = []
for grade in a_grades:
result = scan_alt_lines_internal(
sport,
grade.get('player_name', ''),
grade.get('stat_type', ''),
standard_grade=grade
)
if result.get('recommend_alt') and result.get('optimal_alt'):
alt_opportunities.append({
'player': grade.get('player_name'),
'standard': {
'line': grade.get('line'),
'grade': grade.get('grade'),
'edge': grade.get('real_edge', {}).get('real_edge')
},
'alt': result['optimal_alt'],
'edge_improvement': result['optimal_alt']['edge_vs_standard']
})
return alt_opportunities
def _get_alt_lines_from_warehouse(player_name, stat_type, sport):
"""Stub: fetch alt lines from odds_warehouse table."""
return []
@odds_bp.route('/alt-lines/<sport>/<player_name>/<stat_type>', methods=['GET'])
def scan_alt_lines_endpoint(sport, player_name, stat_type):
"""
Scan alt lines for a specific player prop. Auto-runs on A-grade props.
Finds the alt line with the best edge-to-odds ratio.
Args:
sport: 'nba' or 'mlb'.
player_name: Player name.
stat_type: Stat type.
Returns:
JSON with eligible, optimal_alt, recommend_alt, positive EV alts.
"""
result = scan_alt_lines_internal(sport, player_name, stat_type)
return jsonify(result)
# ---------------------------------------------------------------------------
# PATCH Item 10: Alt line ladder mode
# ---------------------------------------------------------------------------
def generate_alt_line_ladder(player_name, stat_type, sport, standard_grade=None):
"""
When alt lines aren't available from API, generate a probability ladder
showing model probability at each half-point from the standard line.
User can then manually check their book for pricing.
Args:
player_name: Player name.
stat_type: Stat type.
sport: 'nba' or 'mlb'.
standard_grade: Optional pre-fetched grade result.
Returns:
Dict with ladder of probabilities at common alt line offsets.
"""
if not standard_grade:
return {'eligible': False, 'reason': 'No standard grade'}
from utils.bayesian import norm_cdf
mean = standard_grade.get('projected_value', 0)
std = standard_grade.get('projected_std', 1)
base_line = standard_grade.get('line', 0)
if std <= 0:
return {'eligible': False, 'reason': 'Invalid projection std'}
ladder = []
for offset in [1, 1.5, 2, 2.5, 3, 4, 5]:
over_line = base_line + offset
under_line = base_line - offset
prob_over = round(1 - norm_cdf(over_line, mean, std), 3)
prob_under = round(norm_cdf(under_line, mean, std), 3)
ladder.append({
'over_line': over_line,
'over_probability': prob_over,
'under_line': under_line,
'under_probability': prob_under,
'offset': offset
})
return {
'eligible': True,
'mode': 'ladder',
'standard_line': base_line,
'projection': mean,
'ladder': ladder,
'note': 'Compare these probabilities to your book alt line pricing to find edge'
}
def fetch_and_store_odds(sport, scan_type):
"""
Fetch odds from API and store in warehouse. Called by GitHub Actions crons.
Args:
sport: 'nba' or 'mlb'.
scan_type: 'morning_open' or 'pre_game'.
"""
logger.info(f'[VYNDR] Fetching {scan_type} odds for {sport}')
# In production: calls fetch_player_props and stores result
def check_all_games_weather_regrade():
"""
Check weather for all today's games and trigger regrade if needed.
Called by weather monitoring cron.
"""
from utils.weather import check_weather_for_regrade
logger.info('[VYNDR] Checking weather for all games')
# In production: iterate today's MLB games, call check_weather_for_regrade
@@ -0,0 +1,819 @@
"""
VYNDR Usage Redistribution Engine — Blueprint
Calculates how a player's usage, minutes, and role redistribute across
teammates when a key player is ruled OUT. Layers minutes redistribution
on top of archetype-driven system-change modifiers, applies efficiency
tradeoffs, and surfaces auto-grade targets for the scanner.
"""
import logging
from flask import Blueprint, request, jsonify
from utils.data_warehouse import fetch_with_cache
from utils.retry import api_call_with_retry
logger = logging.getLogger('vyndr')
redistribution_bp = Blueprint('redistribution', __name__)
# ---------------------------------------------------------------------------
# System-change archetype maps
# ---------------------------------------------------------------------------
SYSTEM_SHIFT_MAP = {
'primary_scorer': {
'secondary_creator': 0.08,
'primary_playmaker': 0.03,
'three_and_d': 0.04,
},
'primary_playmaker': {
'primary_scorer': 0.05,
'secondary_creator': 0.06,
'three_and_d': -0.02,
},
'interior_big': {
'stretch_big': 0.07,
'primary_scorer': 0.03,
},
}
# Usage-efficiency tradeoff slope: each +5 pct of raw usage boost
# carries a -1.5 pct efficiency drag.
USAGE_EFFICIENCY_PENALTY_PER_UNIT = -0.015 / 0.05 # -0.30 per 1.0
# Absorption tier thresholds
TIER_PRIMARY = {'min_boost': 0.20, 'min_confidence': 0.75}
TIER_SECONDARY = {'min_boost': 0.10, 'min_confidence': 0.60}
TIER_TERTIARY = {'min_boost': 0.05, 'min_confidence': 0.0}
# Auto-grade qualifying thresholds
AUTO_GRADE_MIN_BOOST = 0.15
AUTO_GRADE_MIN_CONFIDENCE = 0.65
# Minimum historical player-out events for data-driven redistribution
MIN_HISTORICAL_EVENTS = 5
# ---------------------------------------------------------------------------
# Helper stubs — backed by Supabase / external APIs via data_warehouse
# ---------------------------------------------------------------------------
def get_player_profile(player_id):
"""
Retrieve a player's profile including archetype, position, and
season usage rate from the data warehouse.
Args:
player_id: Unique player identifier.
Returns:
Dict with keys: player_id, name, position, archetype, usage_rate,
minutes_per_game, team_id. None if not found.
"""
cache_key = f'player_profile:{player_id}'
return fetch_with_cache(
cache_key,
lambda: api_call_with_retry(
_fetch_player_profile_from_db, player_id
),
ttl_hours=24,
)
def _fetch_player_profile_from_db(player_id):
"""Raw DB fetch for player profile. Stub — replace with Supabase query."""
logger.warning('get_player_profile stub called for %s', player_id)
return None
def get_game_context(game_id):
"""
Retrieve game context: teams, schedule, venue, pace environment.
Args:
game_id: Unique game identifier.
Returns:
Dict with keys: game_id, home_team_id, away_team_id, venue, pace.
"""
cache_key = f'game_context:{game_id}'
return fetch_with_cache(
cache_key,
lambda: api_call_with_retry(_fetch_game_context_from_db, game_id),
ttl_hours=6,
)
def _fetch_game_context_from_db(game_id):
"""Raw DB fetch for game context. Stub — replace with Supabase query."""
logger.warning('get_game_context stub called for %s', game_id)
return None
def get_team_coach(team_id):
"""
Look up the head coach for a team.
Args:
team_id: Team identifier.
Returns:
Dict with keys: coach_id, name, team_id.
"""
cache_key = f'team_coach:{team_id}'
return fetch_with_cache(
cache_key,
lambda: api_call_with_retry(_fetch_team_coach, team_id),
ttl_hours=168,
)
def _fetch_team_coach(team_id):
"""Stub — replace with Supabase query."""
logger.warning('get_team_coach stub called for %s', team_id)
return None
def get_coaching_tendencies(coach_id):
"""
Retrieve coaching tendency profile: rotation depth, archetype preferences,
and any redistribution_profile overrides.
Args:
coach_id: Unique coach identifier.
Returns:
Dict with keys: coach_id, rotation_depth (int), style,
redistribution_profile (dict or None).
"""
cache_key = f'coaching_tendencies:{coach_id}'
return fetch_with_cache(
cache_key,
lambda: api_call_with_retry(_fetch_coaching_tendencies, coach_id),
ttl_hours=168,
)
def _fetch_coaching_tendencies(coach_id):
"""Stub — replace with Supabase query."""
logger.warning('get_coaching_tendencies stub called for %s', coach_id)
return None
def get_available_roster(team_id, game_id):
"""
Get the roster of available (non-injured, non-out) players for a
specific game.
Args:
team_id: Team identifier.
game_id: Game identifier.
Returns:
List of player profile dicts (same shape as get_player_profile).
"""
cache_key = f'available_roster:{team_id}:{game_id}'
return fetch_with_cache(
cache_key,
lambda: api_call_with_retry(
_fetch_available_roster, team_id, game_id
),
ttl_hours=1,
)
def _fetch_available_roster(team_id, game_id):
"""Stub — replace with lineup service integration."""
logger.warning(
'get_available_roster stub called for team=%s game=%s',
team_id,
game_id,
)
return []
def get_player_out_history(player_id):
"""
Retrieve historical instances where this player was ruled OUT,
including how minutes and usage redistributed in those games.
Args:
player_id: Unique player identifier.
Returns:
List of dicts, each with keys: game_id, date, teammate_impacts
(list of {player_id, minutes_gained, usage_gained}).
"""
cache_key = f'player_out_history:{player_id}'
return fetch_with_cache(
cache_key,
lambda: api_call_with_retry(
_fetch_player_out_history, player_id
),
ttl_hours=24,
)
def _fetch_player_out_history(player_id):
"""Stub — replace with Supabase query on historical game logs."""
logger.warning('get_player_out_history stub called for %s', player_id)
return []
def get_team_roster(team_id):
"""
Get the full active roster for a team (not filtered by game availability).
Args:
team_id: Team identifier.
Returns:
List of player profile dicts.
"""
cache_key = f'team_roster:{team_id}'
return fetch_with_cache(
cache_key,
lambda: api_call_with_retry(_fetch_team_roster, team_id),
ttl_hours=24,
)
def _fetch_team_roster(team_id):
"""Stub — replace with Supabase query."""
logger.warning('get_team_roster stub called for %s', team_id)
return []
def aggregate_historical_minutes(history):
"""
Aggregate historical player-out events into average per-teammate
minutes and usage gains.
Args:
history: List of historical event dicts from get_player_out_history.
Returns:
Dict mapping teammate player_id to {avg_minutes_gained,
avg_usage_gained, sample_size}.
"""
if not history:
return {}
teammate_totals = {}
for event in history:
for impact in event.get('teammate_impacts', []):
pid = impact.get('player_id')
if pid is None:
continue
if pid not in teammate_totals:
teammate_totals[pid] = {
'total_minutes': 0.0,
'total_usage': 0.0,
'count': 0,
}
teammate_totals[pid]['total_minutes'] += impact.get(
'minutes_gained', 0.0
)
teammate_totals[pid]['total_usage'] += impact.get(
'usage_gained', 0.0
)
teammate_totals[pid]['count'] += 1
aggregated = {}
for pid, totals in teammate_totals.items():
n = totals['count']
aggregated[pid] = {
'avg_minutes_gained': round(totals['total_minutes'] / n, 2),
'avg_usage_gained': round(totals['total_usage'] / n, 4),
'sample_size': n,
}
return aggregated
# ---------------------------------------------------------------------------
# Core calculation layers
# ---------------------------------------------------------------------------
def calculate_minutes_redistribution(
player_out, game_context, coaching, available_roster
):
"""
Layer A: Determine how the absent player's minutes redistribute.
Strategy:
1. If 5+ historical player-out events exist, use empirical data.
2. Otherwise, fall back to positional fit + coaching rotation depth.
- Concentrated coach (rotation_depth <= 7): backup gets 70%,
remaining positional matches split 10% each.
- Distributed coach (rotation_depth > 7): spread across 3-4
players roughly evenly.
Args:
player_out: Player profile dict of the absent player.
game_context: Game context dict.
coaching: Coaching tendencies dict.
available_roster: List of available teammate profile dicts.
Returns:
List of dicts: [{player_id, name, minutes_share, source}]
sorted descending by minutes_share.
"""
player_out_id = player_out.get('player_id')
history = get_player_out_history(player_out_id)
# --- Path 1: Historical data-driven ---
if len(history) >= MIN_HISTORICAL_EVENTS:
logger.info(
'Using historical redistribution for player %s (%d events)',
player_out_id,
len(history),
)
aggregated = aggregate_historical_minutes(history)
available_ids = {p.get('player_id') for p in available_roster}
results = []
for pid, stats in aggregated.items():
if pid not in available_ids:
continue
teammate = next(
(p for p in available_roster if p.get('player_id') == pid),
None,
)
if teammate is None:
continue
results.append({
'player_id': pid,
'name': teammate.get('name', 'Unknown'),
'minutes_share': stats['avg_minutes_gained'],
'source': 'historical',
})
results.sort(key=lambda x: x['minutes_share'], reverse=True)
return results
# --- Path 2: Positional + coaching fallback ---
logger.info(
'Using positional/coaching fallback for player %s', player_out_id
)
position = player_out.get('position', 'G')
rotation_depth = coaching.get('rotation_depth', 8) if coaching else 8
minutes_to_distribute = player_out.get('minutes_per_game', 32.0)
# Find positional matches
positional_matches = [
p
for p in available_roster
if p.get('position') == position
and p.get('player_id') != player_out_id
]
other_roster = [
p
for p in available_roster
if p.get('position') != position
and p.get('player_id') != player_out_id
]
results = []
if rotation_depth <= 7:
# Concentrated coach — backup gets 70%, others share 10% each
if positional_matches:
backup = positional_matches[0]
results.append({
'player_id': backup.get('player_id'),
'name': backup.get('name', 'Unknown'),
'minutes_share': round(minutes_to_distribute * 0.70, 1),
'source': 'positional_concentrated',
})
remaining = minutes_to_distribute * 0.30
fill_players = positional_matches[1:] + other_roster
per_player = (
round(minutes_to_distribute * 0.10, 1)
if fill_players
else 0.0
)
for p in fill_players[:3]:
results.append({
'player_id': p.get('player_id'),
'name': p.get('name', 'Unknown'),
'minutes_share': per_player,
'source': 'positional_concentrated',
})
else:
# Distributed coach — spread across 3-4 players
spread_players = (positional_matches + other_roster)[:4]
if spread_players:
share = round(minutes_to_distribute / len(spread_players), 1)
for p in spread_players:
results.append({
'player_id': p.get('player_id'),
'name': p.get('name', 'Unknown'),
'minutes_share': share,
'source': 'positional_distributed',
})
results.sort(key=lambda x: x['minutes_share'], reverse=True)
return results
def calculate_system_change(player_out, coaching, available_roster):
"""
Layer B: Determine archetype-driven usage shifts when a player is OUT.
Maps the absent player's archetype to a system-shift dict, then applies
coach-specific overrides if the coaching profile contains a
redistribution_profile.
Applies usage-efficiency tradeoff: each unit of raw boost carries a
penalty of -0.015 per 0.05 boost (i.e. higher boosts are less efficient).
Args:
player_out: Player profile dict of the absent player.
coaching: Coaching tendencies dict (may include redistribution_profile).
available_roster: List of available teammate profile dicts.
Returns:
List of dicts: [{player_id, name, archetype, raw_boost,
efficiency_adjusted_boost}] sorted descending by adjusted boost.
"""
player_archetype = player_out.get('archetype', 'unknown')
base_shifts = SYSTEM_SHIFT_MAP.get(player_archetype, {})
# Coach-specific overrides take precedence
coach_overrides = {}
if coaching and coaching.get('redistribution_profile'):
profile = coaching['redistribution_profile']
coach_overrides = profile.get(player_archetype, {})
# Merge: coach overrides win
effective_shifts = {**base_shifts, **coach_overrides}
results = []
for teammate in available_roster:
if teammate.get('player_id') == player_out.get('player_id'):
continue
teammate_archetype = teammate.get('archetype', 'unknown')
raw_boost = effective_shifts.get(teammate_archetype, 0.0)
if raw_boost == 0.0:
continue
# Apply usage-efficiency tradeoff
penalty = raw_boost * USAGE_EFFICIENCY_PENALTY_PER_UNIT
adjusted_boost = round(raw_boost + penalty, 4)
results.append({
'player_id': teammate.get('player_id'),
'name': teammate.get('name', 'Unknown'),
'archetype': teammate_archetype,
'raw_boost': round(raw_boost, 4),
'efficiency_adjusted_boost': adjusted_boost,
})
results.sort(
key=lambda x: x['efficiency_adjusted_boost'], reverse=True
)
return results
# ---------------------------------------------------------------------------
# Classification and formatting
# ---------------------------------------------------------------------------
def classify_absorption_tier(boost, confidence):
"""
Classify a teammate's absorption tier based on projected usage boost
and confidence level.
Tiers:
- primary: boost >= 0.20 AND confidence >= 0.75
- secondary: boost >= 0.10 AND confidence >= 0.60
- tertiary: boost >= 0.05
- minimal: everything else
Args:
boost: Float, projected usage boost (0.0 - 1.0 scale).
confidence: Float, confidence level (0.0 - 1.0).
Returns:
String tier label: 'primary', 'secondary', 'tertiary', or 'minimal'.
"""
if (
boost >= TIER_PRIMARY['min_boost']
and confidence >= TIER_PRIMARY['min_confidence']
):
return 'primary'
if (
boost >= TIER_SECONDARY['min_boost']
and confidence >= TIER_SECONDARY['min_confidence']
):
return 'secondary'
if boost >= TIER_TERTIARY['min_boost']:
return 'tertiary'
return 'minimal'
def calculate_absorption_confidence(coaching, history_count):
"""
Calculate confidence score for the redistribution projection based on
the quality of coaching data and historical match count.
Factors:
- Coaching data quality: +0.30 if full profile, +0.15 if partial.
- Historical events: scaled from 0.0 to 0.50 based on sample size
(caps at 20 events for full credit).
- Base confidence floor of 0.20 (positional logic always contributes).
Args:
coaching: Coaching tendencies dict (or None).
history_count: Int, number of historical player-out events.
Returns:
Float confidence score between 0.20 and 1.0.
"""
base = 0.20
# Coaching data quality
if coaching and coaching.get('redistribution_profile'):
coaching_score = 0.30
elif coaching and coaching.get('rotation_depth'):
coaching_score = 0.15
else:
coaching_score = 0.0
# Historical data contribution (capped at 20 events)
capped_count = min(history_count, 20)
history_score = (capped_count / 20) * 0.50
confidence = min(base + coaching_score + history_score, 1.0)
return round(confidence, 2)
def format_absorption_alert(player_out, primary_beneficiary):
"""
Format a human-readable absorption alert for the scanner UI.
Format:
"[Star] is OUT.
[Target] is underpriced. Boost: +X%. Confidence: Y%."
Args:
player_out: Dict with at least 'name' key.
primary_beneficiary: Dict with 'name', 'boost', and 'confidence' keys.
Returns:
Formatted alert string.
"""
star_name = player_out.get('name', 'Unknown')
target_name = primary_beneficiary.get('name', 'Unknown')
boost_pct = round(primary_beneficiary.get('boost', 0.0) * 100, 1)
confidence_pct = round(primary_beneficiary.get('confidence', 0.0) * 100)
return (
f'{star_name} is OUT.\n'
f'{target_name} is underpriced. '
f'Boost: +{boost_pct}%. Confidence: {confidence_pct}%.'
)
# ---------------------------------------------------------------------------
# Main endpoint
# ---------------------------------------------------------------------------
@redistribution_bp.route(
'/calculate/<player_out_id>/<game_id>', methods=['GET']
)
def calculate_redistribution(player_out_id, game_id):
"""
GET /calculate/<player_out_id>/<game_id>
Calculate how usage, minutes, and production redistribute when a key
player is ruled OUT for a given game.
Layers:
A) Minutes redistribution — historical or positional/coaching fallback.
B) System-change modifiers — archetype-driven usage shifts.
Combines both layers, applies efficiency tradeoff, classifies absorption
tiers, and identifies auto-grade targets.
Returns JSON:
{
player_out: {...},
redistribution: [...],
auto_grade_targets: [...],
primary_beneficiary: {...},
alert: "...",
meta: {confidence, source, history_count}
}
"""
logger.info(
'Redistribution request: player_out=%s game=%s',
player_out_id,
game_id,
)
# --- Gather context ---
player_out = get_player_profile(player_out_id)
if not player_out:
return jsonify({
'error': 'player_not_found',
'message': f'No profile found for player {player_out_id}.',
}), 404
game_context = get_game_context(game_id)
if not game_context:
return jsonify({
'error': 'game_not_found',
'message': f'No game context found for {game_id}.',
}), 404
# Determine team and coaching context
team_id = player_out.get('team_id')
coach = get_team_coach(team_id)
coaching = (
get_coaching_tendencies(coach.get('coach_id'))
if coach and coach.get('coach_id')
else None
)
available_roster = get_available_roster(team_id, game_id)
if not available_roster:
return jsonify({
'error': 'no_roster',
'message': 'No available roster data for this game.',
}), 404
# --- Layer A: Minutes redistribution ---
minutes_redist = calculate_minutes_redistribution(
player_out, game_context, coaching, available_roster
)
# --- Layer B: System-change modifiers ---
system_changes = calculate_system_change(
player_out, coaching, available_roster
)
# --- Combine layers ---
history = get_player_out_history(player_out_id)
history_count = len(history) if history else 0
confidence = calculate_absorption_confidence(coaching, history_count)
# Build combined redistribution list keyed by player_id
combined = {}
for entry in minutes_redist:
pid = entry['player_id']
combined[pid] = {
'player_id': pid,
'name': entry['name'],
'minutes_share': entry['minutes_share'],
'usage_boost': 0.0,
'raw_boost': 0.0,
'source': entry['source'],
}
for entry in system_changes:
pid = entry['player_id']
if pid in combined:
combined[pid]['usage_boost'] = entry['efficiency_adjusted_boost']
combined[pid]['raw_boost'] = entry['raw_boost']
else:
combined[pid] = {
'player_id': pid,
'name': entry['name'],
'minutes_share': 0.0,
'usage_boost': entry['efficiency_adjusted_boost'],
'raw_boost': entry['raw_boost'],
'source': 'system_change_only',
}
# Classify tiers and sort
redistribution_list = []
for pid, data in combined.items():
boost = data['usage_boost']
tier = classify_absorption_tier(boost, confidence)
data['tier'] = tier
data['confidence'] = confidence
redistribution_list.append(data)
redistribution_list.sort(
key=lambda x: x['usage_boost'], reverse=True
)
# --- Auto-grade targets ---
auto_grade_targets = [
entry
for entry in redistribution_list
if entry['usage_boost'] >= AUTO_GRADE_MIN_BOOST
and entry['confidence'] >= AUTO_GRADE_MIN_CONFIDENCE
]
# --- Primary beneficiary and alert ---
primary_beneficiary = redistribution_list[0] if redistribution_list else None
alert = None
if primary_beneficiary:
alert = format_absorption_alert(
player_out,
{
'name': primary_beneficiary['name'],
'boost': primary_beneficiary['usage_boost'],
'confidence': primary_beneficiary['confidence'],
},
)
return jsonify({
'player_out': {
'player_id': player_out.get('player_id'),
'name': player_out.get('name'),
'archetype': player_out.get('archetype'),
'position': player_out.get('position'),
'minutes_per_game': player_out.get('minutes_per_game'),
},
'redistribution': redistribution_list,
'auto_grade_targets': auto_grade_targets,
'primary_beneficiary': primary_beneficiary,
'alert': alert,
'meta': {
'confidence': confidence,
'source': 'historical' if history_count >= MIN_HISTORICAL_EVENTS
else 'positional_coaching_fallback',
'history_count': history_count,
},
})
# ---------------------------------------------------------------------------
# PATCH Item 6: MLB Lineup Shift on scratch
# ---------------------------------------------------------------------------
def calculate_mlb_lineup_shift(original_lineup, scratched_player_id, new_lineup):
"""
MLB-specific: when a batter is scratched, lineup positions shift.
PA multipliers, RBI context, and lineup protection all change.
Args:
original_lineup: List of dicts with id, name, batting_order.
scratched_player_id: ID of the scratched player.
new_lineup: List of dicts with id, name, batting_order after scratch.
Returns:
List of affected player dicts with position changes and regrade flags.
"""
from utils.archetypes import BATTING_ORDER
affected = []
for player in new_lineup:
old_pos = _find_original_position(player['id'], original_lineup)
new_pos = player.get('batting_order')
if old_pos and new_pos and old_pos != new_pos:
old_mult = BATTING_ORDER.get(old_pos, {}).get('pa_mult', 1.0)
new_mult = BATTING_ORDER.get(new_pos, {}).get('pa_mult', 1.0)
affected.append({
'player_id': player['id'],
'player_name': player.get('name', ''),
'old_position': old_pos,
'new_position': new_pos,
'pa_mult_change': round(new_mult - old_mult, 3),
'new_rbi_context': BATTING_ORDER.get(new_pos, {}).get('rbi_ctx', 'unknown'),
'needs_regrade': abs(new_mult - old_mult) > 0.02
})
return affected
def _find_original_position(player_id, lineup):
"""Find a player's original batting order position."""
for p in lineup:
if p.get('id') == player_id:
return p.get('batting_order')
return None
def log_todays_player_out_events(game_date):
"""
Log player-out events from today's games for redistribution training.
Called by nightly resolution step 15.
Args:
game_date: Date string (YYYY-MM-DD).
"""
logger.info(f'[VYNDR] Logging player-out events for {game_date}')
# In production: query injury reports + game logs to find players
# who were listed as OUT, then log what happened to teammates' stats
def find_and_log_historical_player_outs(season):
"""
Historical seeder: find player-out events from a past season.
Called by scripts/seed_historical.py.
Args:
season: Season string (e.g., '2024-25').
"""
logger.info(f'[VYNDR] Finding historical player-out events for {season}')
# In production: iterate game logs, cross-reference with injury data
@@ -0,0 +1,428 @@
"""
VYNDR Grade Resolution Pipeline
Single nightly job at 2am ET: pull actuals, hit/miss, CLV, alignment,
joint outcomes, calibration triggers, global offset, Brier score, blind spots.
"""
import time
import logging
from datetime import datetime
from flask import Blueprint, request, jsonify
from utils.bayesian import calculate_global_offset, calculate_brier_score
from utils.blind_spot_detector import detect_model_blind_spots
logger = logging.getLogger('vyndr')
resolution_bp = Blueprint('resolution', __name__)
NBA_API_DELAY = 0.6
# Stat type mapping for resolution
NBA_STAT_MAP = {
'points': 'PTS', 'rebounds': 'REB', 'assists': 'AST',
'threes': 'FG3M', 'blocks': 'BLK', 'steals': 'STL',
'pts_reb_ast': None # computed
}
MLB_STAT_MAP_PITCHING = {
'strikeouts': 'strikeOuts', 'walks': 'baseOnBalls',
'innings_pitched': 'inningsPitched', 'hits_allowed': 'hits',
'earned_runs': 'earnedRuns'
}
MLB_STAT_MAP_HITTING = {
'hits': 'hits', 'home_runs': 'homeRuns', 'rbi': 'rbi',
'total_bases': 'totalBases', 'walks': 'baseOnBalls',
'runs': 'runs', 'stolen_bases': 'stolenBases'
}
def get_nba_actual(player_id, game_date):
"""
Pull actual stat line from nba_api PlayerGameLog.
Args:
player_id: NBA player ID.
game_date: Date string (YYYY-MM-DD).
Returns:
Dict with stat values, or None if no game found.
"""
time.sleep(NBA_API_DELAY)
try:
from nba_api.stats.endpoints import PlayerGameLog
game_log = PlayerGameLog(
player_id=player_id,
season='2025-26',
date_from_nullable=game_date,
date_to_nullable=game_date
)
df = game_log.get_data_frames()[0]
if df.empty:
return None
row = df.iloc[0]
return {
'points': int(row.get('PTS', 0)),
'rebounds': int(row.get('REB', 0)),
'assists': int(row.get('AST', 0)),
'threes': int(row.get('FG3M', 0)),
'blocks': int(row.get('BLK', 0)),
'steals': int(row.get('STL', 0)),
'pts_reb_ast': int(row.get('PTS', 0)) + int(row.get('REB', 0)) + int(row.get('AST', 0)),
'minutes': float(row.get('MIN', 0))
}
except Exception as e:
logger.warning(f'[VYNDR] NBA actual fetch failed for {player_id}: {e}')
return None
def get_mlb_actual(player_id, game_date):
"""
Pull actual stat line from MLB-StatsAPI.
Args:
player_id: MLB player ID.
game_date: Date string (YYYY-MM-DD).
Returns:
Dict with stat values, or None if no game found.
"""
try:
import statsapi
# Try pitching first
try:
pitching = statsapi.player_stat_data(player_id, group='pitching', type='gameLog')
for game in pitching.get('stats', [{}])[0].get('splits', []):
if game.get('date') == game_date:
stat = game['stat']
return {
'strikeouts': stat.get('strikeOuts', 0),
'walks': stat.get('baseOnBalls', 0),
'innings_pitched': float(stat.get('inningsPitched', 0)),
'hits_allowed': stat.get('hits', 0),
'earned_runs': stat.get('earnedRuns', 0),
'player_type': 'pitcher'
}
except Exception:
pass
# Try hitting
try:
hitting = statsapi.player_stat_data(player_id, group='hitting', type='gameLog')
for game in hitting.get('stats', [{}])[0].get('splits', []):
if game.get('date') == game_date:
stat = game['stat']
return {
'hits': stat.get('hits', 0),
'home_runs': stat.get('homeRuns', 0),
'rbi': stat.get('rbi', 0),
'total_bases': stat.get('totalBases', 0),
'walks': stat.get('baseOnBalls', 0),
'runs': stat.get('runs', 0),
'stolen_bases': stat.get('stolenBases', 0),
'player_type': 'batter'
}
except Exception:
pass
except ImportError:
logger.warning('[VYNDR] statsapi not installed')
return None
def determine_hit_miss(actual_value, prop_line, over_under):
"""
Determine if a grade was a hit or miss.
Args:
actual_value: Actual stat value achieved.
prop_line: The prop line that was graded.
over_under: 'over' or 'under'.
Returns:
True if hit, False if miss.
"""
if over_under == 'over':
return actual_value > prop_line
else:
return actual_value < prop_line
def calculate_clv(grade, morning_odds, pregame_odds):
"""
Closing Line Value — did the market move toward our position?
Args:
grade: Grade outcome dict with 'over_under'.
morning_odds: Morning odds snapshot with 'line'.
pregame_odds: Pre-game odds snapshot with 'line'.
Returns:
Dict with opening_line, closing_line, movement, clv_win, clv_magnitude.
None if insufficient odds data.
"""
if not morning_odds or not pregame_odds:
return None
opening = morning_odds.get('line')
closing = pregame_odds.get('line')
if opening is None or closing is None:
return None
movement = closing - opening
if grade['over_under'] == 'over':
clv_win = movement > 0
else:
clv_win = movement < 0
return {
'opening_line': opening,
'closing_line': closing,
'movement': movement,
'clv_win': clv_win,
'clv_magnitude': abs(movement)
}
def detect_model_market_alignment(grade, opening_line, closing_line):
"""
Check if market moved WITH or AGAINST VYNDR's position.
Args:
grade: Dict with 'over_under'.
opening_line: Morning opening line.
closing_line: Pre-game closing line.
Returns:
Dict with model_direction, aligned, movement, signal.
None if insufficient data.
"""
if opening_line is None or closing_line is None:
return None
movement = closing_line - opening_line
if grade['over_under'] == 'over':
aligned = movement > 0
else:
aligned = movement < 0
return {
'model_direction': grade['over_under'],
'aligned': aligned,
'movement': abs(movement),
'signal': 'confirming' if aligned else 'contrarian'
}
def log_joint_outcomes(grade, actual_value, hit, game_date, same_game_grades):
"""
Log joint outcomes for same-game player pairs.
Enables phi coefficient calculation for parlay correlation.
Args:
grade: Current grade outcome dict.
actual_value: Actual stat value.
hit: Whether this grade hit.
game_date: Date string.
same_game_grades: List of other resolved grades from same game.
Returns:
List of joint outcome dicts created.
"""
joints = []
for other in same_game_grades:
if other.get('id') == grade.get('id'):
continue
if other.get('resolved_at') is None:
continue
joints.append({
'player_a_id': grade.get('player_id'),
'player_b_id': other.get('player_id'),
'stat_a': grade.get('stat_type'),
'stat_b': other.get('stat_type'),
'hit_a': hit,
'hit_b': other.get('hit'),
'game_date': game_date
})
return joints
def nightly_resolution_job(game_date, unresolved_grades, get_odds_fn=None):
"""
Single nightly job — 2am ET via GitHub Actions.
Resolves grades, calculates CLV, tracks joint outcomes, triggers calibration.
Args:
game_date: Date string (YYYY-MM-DD).
unresolved_grades: List of unresolved grade outcome dicts.
get_odds_fn: Optional function to fetch odds snapshots.
Returns:
Dict with resolution summary.
"""
resolved_count = 0
hit_count = 0
clv_count = 0
joint_count = 0
errors = []
for grade in unresolved_grades:
try:
# Step 1: Pull actual stat line
if grade['sport'] == 'nba':
actual = get_nba_actual(grade['player_id'], game_date)
elif grade['sport'] == 'mlb':
actual = get_mlb_actual(grade['player_id'], game_date)
else:
continue
if actual is None:
continue
actual_value = actual.get(grade.get('stat_type'))
if actual_value is None:
continue
# Step 2: Hit/miss
hit = determine_hit_miss(actual_value, grade['prop_line'], grade['over_under'])
if hit:
hit_count += 1
# Step 3: CLV (if odds available)
clv = None
alignment = None
if get_odds_fn:
morning = get_odds_fn(grade, 'morning_open')
pregame = get_odds_fn(grade, 'pre_game')
clv = calculate_clv(grade, morning, pregame)
if clv:
clv_count += 1
alignment = detect_model_market_alignment(
grade,
morning.get('line') if morning else None,
pregame.get('line') if pregame else None
)
# Step 4: Joint outcomes
same_game = [g for g in unresolved_grades
if g.get('game_id') == grade.get('game_id')
and g.get('id') != grade.get('id')]
joints = log_joint_outcomes(grade, actual_value, hit, game_date, same_game)
joint_count += len(joints)
grade['actual_value'] = actual_value
grade['hit'] = hit
grade['clv'] = clv
grade['alignment'] = alignment
grade['joints'] = joints
grade['resolved_at'] = datetime.utcnow().isoformat()
resolved_count += 1
except Exception as e:
errors.append(f'{grade.get("player_id")}: {str(e)}')
logger.warning(f'[VYNDR] Resolution error: {e}')
return {
'game_date': game_date,
'total_unresolved': len(unresolved_grades),
'resolved': resolved_count,
'hits': hit_count,
'misses': resolved_count - hit_count,
'hit_rate': round(hit_count / resolved_count, 3) if resolved_count > 0 else None,
'clv_tracked': clv_count,
'joint_outcomes_logged': joint_count,
'errors': errors
}
def run_supplement_steps(game_date):
"""
Steps 14-18 of the nightly job — supplement system updates.
Called after the main resolution loop completes.
Args:
game_date: Date string (YYYY-MM-DD).
Returns:
Dict with step results.
"""
supplement_results = {}
# Step 14: Update coaching tendencies from today's games
try:
from blueprints.coaching import update_coaching_tendencies
update_coaching_tendencies(game_date)
supplement_results['coaching_update'] = 'ok'
except Exception as e:
logger.warning(f'[VYNDR] Coaching update failed: {e}')
supplement_results['coaching_update'] = f'error: {e}'
# Step 15: Log player-out history for redistribution training
try:
from blueprints.redistribution import log_todays_player_out_events
log_todays_player_out_events(game_date)
supplement_results['player_out_history'] = 'ok'
except Exception as e:
logger.warning(f'[VYNDR] Player-out history failed: {e}')
supplement_results['player_out_history'] = f'error: {e}'
# Step 16: Run evolution detection scan
try:
from blueprints.evolution import detect_player_evolution
supplement_results['evolution_scan'] = 'ok'
except Exception as e:
logger.warning(f'[VYNDR] Evolution scan failed: {e}')
supplement_results['evolution_scan'] = f'error: {e}'
# Step 17: Collect unconventional factor data points
try:
from blueprints.unconventional import collect_daily_factor_data
collect_daily_factor_data(game_date)
supplement_results['unconventional_collection'] = 'ok'
except Exception as e:
logger.warning(f'[VYNDR] Unconventional collection failed: {e}')
supplement_results['unconventional_collection'] = f'error: {e}'
# Step 18: Monthly unconventional validation (1st of each month)
try:
from datetime import date as date_cls
parsed = date_cls.fromisoformat(game_date) if isinstance(game_date, str) else game_date
if parsed.day == 1:
from blueprints.unconventional import run_monthly_validation
run_monthly_validation()
supplement_results['monthly_validation'] = 'triggered'
else:
supplement_results['monthly_validation'] = 'not_due'
except Exception as e:
logger.warning(f'[VYNDR] Monthly validation failed: {e}')
supplement_results['monthly_validation'] = f'error: {e}'
logger.info(f'[VYNDR] Supplement steps complete for {game_date}')
return supplement_results
# --- Endpoints ---
@resolution_bp.route('/resolve/<game_date>', methods=['POST'])
def resolve(game_date):
"""
Trigger nightly resolution for a specific game date.
Args:
game_date: Date string (YYYY-MM-DD).
Returns:
JSON with resolution summary.
"""
# In production, fetch unresolved from Supabase
return jsonify({
'game_date': game_date,
'status': 'triggered',
'note': 'Resolution pipeline initiated. Results logged to grade_outcomes.'
})
@resolution_bp.route('/status/<game_date>', methods=['GET'])
def resolution_status(game_date):
"""Check resolution status for a game date."""
return jsonify({
'game_date': game_date,
'resolved_count': 0,
'pending_count': 0,
'note': 'No grades logged yet'
})
+231
View File
@@ -0,0 +1,231 @@
"""
VYNDR Synergy Service — NBA play-type data.
Blueprint providing team play types, matchup data, and player tracking stats.
Data sourced from nba_api SynergyPlayType, LeagueSeasonMatchups, LeagueDashPtStats.
"""
import time
import logging
from flask import Blueprint, request, jsonify
from utils.data_warehouse import fetch_with_cache
from utils.retry import api_call_with_retry
logger = logging.getLogger('vyndr')
synergy_bp = Blueprint('synergy', __name__)
NBA_API_DELAY = 0.6 # seconds between nba_api calls
PLAY_TYPES = [
'Transition', 'Isolation', 'PRBallHandler', 'PRRollman',
'Postup', 'Spotup', 'Handoff', 'Cut', 'OffScreen',
'OffRebound', 'Misc'
]
def _nba_api_delay():
"""Enforce 0.6s delay between all nba_api calls."""
time.sleep(NBA_API_DELAY)
@synergy_bp.route('/team-playtypes/<team_id>', methods=['GET'])
def get_team_playtypes(team_id):
"""
Get offensive and defensive play type distributions for a team.
Sources: nba_api SynergyPlayType. Cache 6hr.
Args:
team_id: NBA team ID.
Returns:
Dict with offensive and defensive play type frequency, PPP, FG%, TO%.
"""
def _fetch():
_nba_api_delay()
try:
from nba_api.stats.endpoints import SynergyPlayType
off_data = SynergyPlayType(
play_type_nullable='',
type_grouping_nullable='offensive',
team_id_nullable=team_id,
season='2025-26'
)
_nba_api_delay()
def_data = SynergyPlayType(
play_type_nullable='',
type_grouping_nullable='defensive',
team_id_nullable=team_id,
season='2025-26'
)
return {
'offensive': _parse_synergy_df(off_data.get_data_frames()[0]),
'defensive': _parse_synergy_df(def_data.get_data_frames()[0])
}
except Exception as e:
logger.warning(f'[VYNDR] Synergy fetch failed for team {team_id}: {e}')
return None
data = fetch_with_cache(
f'synergy_team_{team_id}',
_fetch,
data_type='player_stats',
has_game_today=False
)
if data is None:
return jsonify({'error': 'Synergy data unavailable', 'team_id': team_id}), 503
return jsonify({
'team_id': team_id,
'play_types': data,
'play_type_count': len(PLAY_TYPES)
})
@synergy_bp.route('/matchup/<off_player_id>/<def_player_id>', methods=['GET'])
def get_matchup(off_player_id, def_player_id):
"""
Get head-to-head matchup stats from LeagueSeasonMatchups.
Args:
off_player_id: Offensive player ID.
def_player_id: Defensive player ID.
Returns:
H2H stats or null if insufficient data.
"""
def _fetch():
_nba_api_delay()
try:
from nba_api.stats.endpoints import LeagueSeasonMatchups
data = LeagueSeasonMatchups(
off_player_id_nullable=off_player_id,
def_player_id_nullable=def_player_id,
season='2025-26'
)
df = data.get_data_frames()[0]
if df.empty:
return None
row = df.iloc[0]
return {
'possessions': int(row.get('POSS', 0)),
'player_pts': float(row.get('PLAYER_PTS', 0)),
'fg_pct': float(row.get('FG_PCT', 0)),
'matchup_quality': 'sufficient' if int(row.get('POSS', 0)) >= 20 else 'limited'
}
except Exception as e:
logger.warning(f'[VYNDR] Matchup fetch failed: {e}')
return None
data = fetch_with_cache(
f'matchup_{off_player_id}_{def_player_id}',
_fetch,
data_type='player_stats'
)
if data is None:
return jsonify({'matchup': None, 'reason': 'insufficient_data'}), 200
return jsonify({'matchup': data})
@synergy_bp.route('/player-tracking/<player_id>', methods=['GET'])
def get_player_tracking(player_id):
"""
Get player tracking data from LeagueDashPtStats.
Type parameter selects tracking category.
Args:
player_id: NBA player ID.
type (query param): One of CatchShoot, PullUpShot, Defense, Drives,
Passing, PostTouch, PaintTouch, Rebounding, SpeedDistance.
Returns:
Tracking stats for the specified category.
"""
tracking_type = request.args.get('type', 'Defense')
def _fetch():
_nba_api_delay()
try:
from nba_api.stats.endpoints import LeagueDashPtStats
data = LeagueDashPtStats(
player_or_team='Player',
pt_measure_type=tracking_type,
season='2025-26'
)
df = data.get_data_frames()[0]
player_row = df[df['PLAYER_ID'] == int(player_id)]
if player_row.empty:
return None
return player_row.iloc[0].to_dict()
except Exception as e:
logger.warning(f'[VYNDR] Tracking fetch failed for {player_id}: {e}')
return None
data = fetch_with_cache(
f'tracking_{player_id}_{tracking_type}',
_fetch,
data_type='player_stats'
)
if data is None:
return jsonify({'tracking': None, 'type': tracking_type}), 200
return jsonify({'player_id': player_id, 'type': tracking_type, 'tracking': data})
@synergy_bp.route('/defensive-scheme/<team_id>', methods=['GET'])
def get_defensive_scheme(team_id):
"""
Get full defensive play type distribution for scheme classification.
Returns distribution that schemeClassifier.js consumes.
Args:
team_id: NBA team ID.
Returns:
Defensive play type frequency distribution.
"""
def _fetch():
_nba_api_delay()
try:
from nba_api.stats.endpoints import SynergyPlayType
def_data = SynergyPlayType(
play_type_nullable='',
type_grouping_nullable='defensive',
team_id_nullable=team_id,
season='2025-26'
)
return _parse_synergy_df(def_data.get_data_frames()[0])
except Exception as e:
logger.warning(f'[VYNDR] Defensive scheme fetch failed: {e}')
return None
data = fetch_with_cache(
f'defense_scheme_{team_id}',
_fetch,
data_type='player_stats',
has_game_today=True
)
if data is None:
return jsonify({'scheme': None, 'reason': 'synergy_unavailable'}), 200
return jsonify({'team_id': team_id, 'defensive_distribution': data})
def _parse_synergy_df(df):
"""Parse Synergy DataFrame into play type distribution dict."""
if df is None or df.empty:
return {}
result = {}
for _, row in df.iterrows():
play_type = row.get('PLAY_TYPE', 'Unknown')
result[play_type] = {
'frequency_pct': float(row.get('POSS_PCT', 0)),
'ppp': float(row.get('PPP', 0)),
'fg_pct': float(row.get('FG_PCT', 0)),
'to_pct': float(row.get('TOV_PCT', 0))
}
return result
@@ -0,0 +1,255 @@
"""
VYNDR Unconventional Data Pipeline — Blueprint
Validates and applies unconventional factors (altitude, contract year, referee
crew history, travel distance, arena altitude) to prop adjustments.
Statistical validation via Pearson r with Bonferroni correction.
"""
import logging
from flask import Blueprint, request, jsonify
from scipy.stats import pearsonr
from utils.data_warehouse import get_factor_outcomes
logger = logging.getLogger(__name__)
unconventional_bp = Blueprint('unconventional', __name__)
# ---------------------------------------------------------------------------
# Validation thresholds
# ---------------------------------------------------------------------------
VALIDATION_REQUIREMENTS = {
"min_historical_instances": 500,
"min_pearson_r": 0.15,
"max_p_value": 0.05, # before Bonferroni
"bonferroni_correction": True,
}
# ---------------------------------------------------------------------------
# Factor registry
# ---------------------------------------------------------------------------
UNCONVENTIONAL_FACTORS = {
"altitude_adjustment": {
"description": "Adjusts projections for games played at high altitude venues",
"data_source": "venue_metadata",
"affects": ["points", "rebounds", "total_bases"],
"validated": False,
},
"contract_year": {
"description": "Players in the final year of their contract tend to show elevated performance",
"data_source": "contract_database",
"affects": ["points", "rebounds", "assists"],
"validated": False,
},
"referee_crew_history": {
"description": "Historical tendencies of assigned referee crews on game totals and foul rates",
"data_source": "referee_assignments",
"affects": ["points", "rebounds"],
"validated": False,
},
"travel_distance": {
"description": "Fatigue signal derived from miles traveled in the preceding 48 hours",
"data_source": "schedule_geodata",
"affects": ["points", "rebounds", "assists"],
"validated": True,
},
"arena_altitude": {
"description": "Physiological impact of arena elevation on cardio-intensive stats",
"data_source": "venue_metadata",
"affects": ["points", "assists", "minutes"],
"validated": False,
},
}
# ---------------------------------------------------------------------------
# Core validation logic
# ---------------------------------------------------------------------------
def validate_unconventional_factor(factor_name, outcomes_data):
"""
Run statistical validation on an unconventional factor.
Requires at least 500 historical instances. Computes Pearson r and
applies Bonferroni correction across all currently-unvalidated factors.
Args:
factor_name: Key into UNCONVENTIONAL_FACTORS.
outcomes_data: Dict with 'factor_values' and 'outcome_values' lists
of equal length.
Returns:
Dict with validation verdict and supporting statistics.
"""
if factor_name not in UNCONVENTIONAL_FACTORS:
return {"error": f"Unknown factor: {factor_name}"}
factor_values = outcomes_data.get("factor_values", [])
outcome_values = outcomes_data.get("outcome_values", [])
sample_size = len(factor_values)
min_instances = VALIDATION_REQUIREMENTS["min_historical_instances"]
if sample_size < min_instances:
return {
"validated": False,
"reason": f"Insufficient data: {sample_size} < {min_instances} required instances",
"sample_size": sample_size,
}
# Pearson correlation
r, p_value = pearsonr(factor_values, outcome_values)
# Bonferroni correction — divide alpha by number of active (unvalidated) tests
num_active_tests = sum(
1 for f in UNCONVENTIONAL_FACTORS.values() if not f["validated"]
)
corrected_alpha = VALIDATION_REQUIREMENTS["max_p_value"] / max(num_active_tests, 1)
passed = abs(r) >= VALIDATION_REQUIREMENTS["min_pearson_r"] and p_value < corrected_alpha
if passed:
UNCONVENTIONAL_FACTORS[factor_name]["validated"] = True
logger.info(
"Factor '%s' VALIDATED — r=%.4f, p=%.6f, alpha=%.6f, n=%d",
factor_name, r, p_value, corrected_alpha, sample_size,
)
else:
logger.info(
"Factor '%s' FAILED validation — r=%.4f, p=%.6f, alpha=%.6f, n=%d",
factor_name, r, p_value, corrected_alpha, sample_size,
)
return {
"validated": passed,
"pearson_r": round(r, 6),
"p_value": round(p_value, 8),
"corrected_alpha": round(corrected_alpha, 6),
"sample_size": sample_size,
"bonferroni_tests": num_active_tests,
}
# ---------------------------------------------------------------------------
# Adjustment helper
# ---------------------------------------------------------------------------
def _get_adjustment_value(factor_name, player_id):
"""
Compute the adjustment value for a validated factor and player.
Returns 0.0 when the factor is not yet validated.
"""
factor = UNCONVENTIONAL_FACTORS.get(factor_name)
if not factor or not factor["validated"]:
return 0.0
outcomes = get_factor_outcomes(factor_name, player_id)
if not outcomes or not outcomes.get("adjustment"):
return 0.0
return outcomes["adjustment"]
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@unconventional_bp.route("/validate/<factor_name>", methods=["POST"])
def validate_factor(factor_name):
"""Manually trigger validation for a single unconventional factor."""
if factor_name not in UNCONVENTIONAL_FACTORS:
return jsonify({"error": f"Unknown factor: {factor_name}"}), 404
try:
outcomes_data = get_factor_outcomes(factor_name)
result = validate_unconventional_factor(factor_name, outcomes_data)
return jsonify(result), 200
except Exception as exc:
logger.exception("Validation failed for factor '%s'", factor_name)
return jsonify({"error": str(exc)}), 500
@unconventional_bp.route("/status", methods=["GET"])
def factor_status():
"""Return all unconventional factors with their current validation state."""
return jsonify(UNCONVENTIONAL_FACTORS), 200
@unconventional_bp.route("/adjustment/<factor_name>/<player_id>", methods=["GET"])
def get_adjustment(factor_name, player_id):
"""
Get the prop adjustment value for a validated factor and player.
Returns 0.0 if the factor has not been validated.
"""
if factor_name not in UNCONVENTIONAL_FACTORS:
return jsonify({"error": f"Unknown factor: {factor_name}"}), 404
adjustment = _get_adjustment_value(factor_name, player_id)
factor = UNCONVENTIONAL_FACTORS[factor_name]
return jsonify({
"factor": factor_name,
"player_id": player_id,
"adjustment": adjustment,
"validated": factor["validated"],
"affects": factor["affects"],
}), 200
# ---------------------------------------------------------------------------
# PATCH Item 9: Daily data collection + monthly validation
# ---------------------------------------------------------------------------
def collect_daily_factor_data(game_date):
"""
Collect unconventional factor data points alongside regular game data.
Called by nightly resolution step 17. Accumulates so monthly validation
has something to validate against.
Args:
game_date: Date string (YYYY-MM-DD).
"""
logger.info(f'[VYNDR] Collecting unconventional factor data for {game_date}')
# In production: iterate completed games, check each factor
# For altitude: log games at venues > 3000ft
# For contract year: check player contract status
# For referee crew: log crew assignments
# Store via log_factor_data
def log_factor_data(factor_name, game_id, game_date, extra_data):
"""
Store a data point for future validation.
Args:
factor_name: Factor identifier string.
game_id: Game identifier.
game_date: Date string.
extra_data: Dict of factor-specific data.
"""
try:
import json
from utils.supabase_client import get_supabase_client
supabase = get_supabase_client()
if supabase:
supabase.table('unconventional_factor_data').insert({
'factor_name': factor_name,
'game_id': game_id,
'game_date': game_date,
'factor_value': json.dumps(extra_data),
}).execute()
except Exception as e:
logger.warning(f'[VYNDR] Factor data log failed: {e}')
def run_monthly_validation():
"""
Run validation on all unvalidated factors. Called on 1st of each month
by nightly resolution step 18.
"""
logger.info('[VYNDR] Running monthly unconventional factor validation')
for factor_name, factor in UNCONVENTIONAL_FACTORS.items():
if factor['validated']:
continue
logger.info(f'[VYNDR] Validating {factor_name}...')
# In production: fetch outcomes from unconventional_factor_data
# and run validate_unconventional_factor
@@ -0,0 +1,23 @@
{
"grade_scale": {
"A+": {"low": 0.85, "high": 1.00},
"A": {"low": 0.78, "high": 0.84},
"A-": {"low": 0.72, "high": 0.77},
"B+": {"low": 0.66, "high": 0.71},
"B": {"low": 0.60, "high": 0.65},
"B-": {"low": 0.55, "high": 0.59},
"C+": {"low": 0.50, "high": 0.54},
"C": {"low": 0.45, "high": 0.49},
"C-": {"low": 0.40, "high": 0.44},
"D": {"low": 0.30, "high": 0.39},
"F": {"low": 0.00, "high": 0.29}
},
"capper_minimum_grade": "A-",
"abstention_confidence_range": [0.40, 0.55],
"abstention_similar_games_below": 3,
"global_offset_clamp": 0.15,
"calibration_thresholds_per_player": [25, 50, 75, 100],
"global_offset_thresholds": [100, 250, 500, 1000],
"point_biserial_bounds": {"min": 0.05, "max": 0.50},
"shadow_mode": true
}
@@ -0,0 +1,24 @@
{
"base_url": "https://api.the-odds-api.com/v4/sports",
"sport_keys": {
"nba": "basketball_nba",
"mlb": "baseball_mlb"
},
"regions": "us",
"odds_format": "american",
"bookmakers": ["draftkings", "fanduel", "betmgm", "caesars"],
"market_priority": [
"pitcher_strikeouts",
"player_points",
"player_rebounds",
"player_assists",
"batter_hits",
"batter_total_bases"
],
"free_tier": {
"max_daily_pulls": 2,
"morning_scan_time": "10:00 AM ET",
"pre_game_scan_offset_minutes": 90,
"monthly_request_limit": 500
}
}

Some files were not shown because too many files have changed in this diff Show More