Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
+104
@@ -1,5 +1,6 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const oddsRoutes = require('./routes/odds');
|
||||
const analyzeRoutes = require('./routes/analyze');
|
||||
const scanRoutes = require('./routes/scan');
|
||||
@@ -7,13 +8,104 @@ const movementsRoutes = require('./routes/movements');
|
||||
const alertsRoutes = require('./routes/alerts');
|
||||
const betsRoutes = require('./routes/bets');
|
||||
const stripeRoutes = require('./routes/stripe');
|
||||
const statsRoutes = require('./routes/stats');
|
||||
const propsRoutes = require('./routes/props');
|
||||
const waitlistRoutes = require('./routes/waitlist');
|
||||
const pipelineRoutes = require('./routes/pipeline');
|
||||
const shareCardRoutes = require('./routes/shareCard');
|
||||
const pushRoutes = require('./routes/push');
|
||||
const gradingRoutes = require('./routes/grading');
|
||||
const correctionRoutes = require('./routes/corrections');
|
||||
const { missionHeader } = require('./middleware/mission');
|
||||
|
||||
const app = express();
|
||||
|
||||
// CORS — accept the Next.js frontend on Vercel/production and localhost dev.
|
||||
// FRONTEND_ORIGINS overrides at deploy time (comma-separated).
|
||||
const defaultOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'https://vyndr.app',
|
||||
'https://www.vyndr.app',
|
||||
];
|
||||
const envOrigins = (process.env.FRONTEND_ORIGINS || '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const allowedOrigins = [...new Set([...defaultOrigins, ...envOrigins])];
|
||||
app.use(
|
||||
cors({
|
||||
origin(origin, cb) {
|
||||
// Allow same-origin (no Origin header) and the configured allowlist.
|
||||
// Also allow any *.vercel.app preview for staging.
|
||||
if (!origin) return cb(null, true);
|
||||
if (allowedOrigins.includes(origin)) return cb(null, true);
|
||||
if (/\.vercel\.app$/.test(new URL(origin).hostname)) return cb(null, true);
|
||||
return cb(new Error(`Origin ${origin} not allowed`));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
})
|
||||
);
|
||||
|
||||
// Mission header on all responses
|
||||
app.use(missionHeader);
|
||||
|
||||
// Stripe webhook needs raw body — must be before express.json()
|
||||
app.use('/api/stripe/webhook', express.raw({ type: 'application/json' }));
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Health check — public minimal status (Coolify, uptime monitors). Detailed
|
||||
// adapter + Python service status only with X-VYNDR-Internal-Key.
|
||||
app.get('/api/health', async (req, res) => {
|
||||
const checks = {};
|
||||
|
||||
try {
|
||||
const { getRedisClient, isDegraded } = require('./utils/redis');
|
||||
if (isDegraded()) throw new Error('degraded');
|
||||
await getRedisClient().ping();
|
||||
checks.redis = 'ok';
|
||||
} catch { checks.redis = 'down'; }
|
||||
|
||||
try {
|
||||
const { getSupabaseServiceClient } = require('./utils/supabase');
|
||||
const { error } = await getSupabaseServiceClient().from('users').select('id').limit(1);
|
||||
checks.supabase = error ? 'error' : 'ok';
|
||||
} catch { checks.supabase = 'down'; }
|
||||
|
||||
const healthy = checks.redis === 'ok' && checks.supabase === 'ok';
|
||||
const expectedKey = process.env.VYNDR_INTERNAL_KEY;
|
||||
const providedKey = req.headers['x-vyndr-internal-key'];
|
||||
|
||||
if (expectedKey && providedKey === expectedKey) {
|
||||
try {
|
||||
const axios = require('axios');
|
||||
const pyUrl = process.env.PYTHON_SERVICE_URL || 'http://localhost:8000';
|
||||
await axios.get(`${pyUrl}/health`, { timeout: 3_000 });
|
||||
checks.python = 'ok';
|
||||
} catch { checks.python = 'down'; }
|
||||
|
||||
checks.adapters = {
|
||||
sharpapi: require('./services/adapters/sharpApiAdapter').configured(),
|
||||
propodds: require('./services/adapters/propOddsAdapter').configured(),
|
||||
parlayapi: require('./services/adapters/parlayApiAdapter').configured(),
|
||||
oddspapi: require('./services/adapters/oddsPapiAdapter').configured(),
|
||||
cfbd: require('./services/adapters/cfbdAdapter').configured(),
|
||||
openrouter: require('./services/adapters/openRouterAdapter').configured(),
|
||||
};
|
||||
checks.engine2_enabled = process.env.ENGINE2_ENABLED === 'true';
|
||||
|
||||
return res.status(healthy ? 200 : 503).json({
|
||||
status: healthy ? 'healthy' : 'degraded',
|
||||
checks,
|
||||
version: require('../package.json').version || '1.0.0',
|
||||
uptime: Math.floor(process.uptime()),
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(healthy ? 200 : 503).json({
|
||||
status: healthy ? 'healthy' : 'degraded',
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/odds', oddsRoutes);
|
||||
app.use('/api/analyze', analyzeRoutes);
|
||||
app.use('/api/scan', scanRoutes);
|
||||
@@ -21,5 +113,17 @@ app.use('/api/movements', movementsRoutes);
|
||||
app.use('/api/alerts', alertsRoutes);
|
||||
app.use('/api/bets', betsRoutes);
|
||||
app.use('/api/stripe', stripeRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
app.use('/api/props', propsRoutes);
|
||||
app.use('/api/waitlist', waitlistRoutes);
|
||||
app.use('/api/pipeline', pipelineRoutes);
|
||||
app.use('/api/share-card', shareCardRoutes);
|
||||
app.use('/api/push', pushRoutes);
|
||||
// Resolution payloads carry full ESPN box scores (50-100KB). Scope a larger
|
||||
// limit to /api/grading only so the other routes keep the safer 100KB default.
|
||||
app.use('/api/grading', express.json({ limit: '2mb' }), gradingRoutes);
|
||||
app.use('/api/grading', express.json({ limit: '256kb' }), correctionRoutes);
|
||||
const widgetRoutes = require('./routes/widget');
|
||||
app.use('/api/widget', widgetRoutes);
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"_meta": {
|
||||
"purpose": "Seed coach profiles. Loaded into coach_profiles on first cold-start. Update when a coaching change happens, at season start, and at the trade deadline.",
|
||||
"fields": "coach_name, team, sport, career_avg_pace, current_team_pace, tenure_games, primary_player, system_style, without_primary_style, without_primary_pace_delta",
|
||||
"kev_action": "Populate with researched values per coach. The structure below has the canonical starters as placeholders so the cold-path doesn't crash on empty tables."
|
||||
},
|
||||
"coaches": [
|
||||
{
|
||||
"coach_name": "Tom Thibodeau",
|
||||
"team": "NYK",
|
||||
"sport": "nba",
|
||||
"career_avg_pace": 96.5,
|
||||
"current_team_pace": 98.1,
|
||||
"tenure_games": 320,
|
||||
"primary_player": "Jalen Brunson",
|
||||
"system_style": "half_court_iso",
|
||||
"without_primary_style": "motion",
|
||||
"without_primary_pace_delta": 1.5
|
||||
},
|
||||
{
|
||||
"coach_name": "Joe Mazzulla",
|
||||
"team": "BOS",
|
||||
"sport": "nba",
|
||||
"career_avg_pace": 99.5,
|
||||
"current_team_pace": 100.1,
|
||||
"tenure_games": 180,
|
||||
"primary_player": "Jayson Tatum",
|
||||
"system_style": "motion",
|
||||
"without_primary_style": "transition",
|
||||
"without_primary_pace_delta": 2.0
|
||||
},
|
||||
{
|
||||
"coach_name": "Erik Spoelstra",
|
||||
"team": "MIA",
|
||||
"sport": "nba",
|
||||
"career_avg_pace": 95.8,
|
||||
"current_team_pace": 96.0,
|
||||
"tenure_games": 1200,
|
||||
"primary_player": "Jimmy Butler",
|
||||
"system_style": "half_court_iso",
|
||||
"without_primary_style": "motion",
|
||||
"without_primary_pace_delta": 1.2
|
||||
},
|
||||
{
|
||||
"coach_name": "Sandy Brondello",
|
||||
"team": "NYL",
|
||||
"sport": "wnba",
|
||||
"career_avg_pace": 80.5,
|
||||
"current_team_pace": 81.2,
|
||||
"tenure_games": 110,
|
||||
"primary_player": "Breanna Stewart",
|
||||
"system_style": "motion",
|
||||
"without_primary_style": "motion",
|
||||
"without_primary_pace_delta": 0.5
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Sport configuration — two layers in one file.
|
||||
*
|
||||
* SPORTS (legacy, UI-facing)
|
||||
* `active`, `collectData`, `comingSoon` — drives the landing-page badges
|
||||
* and the "is grading on for this sport" gate. Consumed by:
|
||||
* - src/services/UnifiedOddsProvider.js (shouldCollect)
|
||||
* - src/routes/pipeline.js (isActiveSport)
|
||||
* Mirror in `web/src/config/sports.ts`.
|
||||
*
|
||||
* SPORT_CONFIG (pipeline / resolution / poller)
|
||||
* Per-sport ESPN endpoints + stat parsers. The resolution route, the
|
||||
* ESPN poller, and the odds adapters all read from here. Active across
|
||||
* all 7 graded sports — when a sport's UI flag is off but pipeline is
|
||||
* on, we collect data without surfacing grades.
|
||||
*
|
||||
* Game hours are stored as ET (Eastern Time). Pollers convert from UTC
|
||||
* with Intl.DateTimeFormat — see `getETHour()` in poller/poller.js.
|
||||
*
|
||||
* Stat parsing supports FOUR formats; calculateStat in the resolution
|
||||
* route picks based on which key is present:
|
||||
* 1. idx + parse → flat stats[] (NBA / WNBA / NCAAB)
|
||||
* 2. mlbField → MLB Stats API native field name
|
||||
* 3. category + field → NFL / NCAAFB category-based athletes
|
||||
* 4. calc / mlbCalc → combo stat computed from components
|
||||
* 5. field → NHL named-field box score
|
||||
*
|
||||
* All parse functions MUST be defensive — undefined/null/empty input must
|
||||
* return 0 rather than NaN, otherwise resolution divides by NaN and bricks
|
||||
* an entire sport's batch.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Legacy UI config (unchanged) — keep stable for existing consumers.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const SPORTS = Object.freeze({
|
||||
nba: { key: 'nba', label: 'NBA', color: '#E94B3C', active: true, collectData: true },
|
||||
wnba: { key: 'wnba', label: 'WNBA', color: '#F7944A', active: true, collectData: true },
|
||||
mlb: { key: 'mlb', label: 'MLB', color: '#1E90FF', active: true, collectData: true },
|
||||
nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
});
|
||||
|
||||
const ALL = Object.values(SPORTS);
|
||||
const ACTIVE = ALL.filter((s) => s.active);
|
||||
const COLLECTING = ALL.filter((s) => s.collectData);
|
||||
|
||||
const isActiveSport = (key) => !!SPORTS[String(key || '').toLowerCase()]?.active;
|
||||
const shouldCollect = (key) => !!SPORTS[String(key || '').toLowerCase()]?.collectData;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Stat parsers — defensive helpers.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const numOrZero = (v) => {
|
||||
if (v === undefined || v === null || v === '') return 0;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
// "3-7" → 3 (makes-attempts string format used in NBA threes/FG/FT)
|
||||
const splitMakes = (v) => {
|
||||
if (!v) return 0;
|
||||
const left = String(v).split('-')[0];
|
||||
const n = Number(left);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
// "5.1" innings pitched → 5.333... (decimal innings)
|
||||
const inningsToDecimal = (v) => {
|
||||
if (!v) return 0;
|
||||
const s = String(v);
|
||||
const [whole, partial] = s.split('.');
|
||||
const w = Number(whole) || 0;
|
||||
const p = Number(partial) || 0;
|
||||
return w + p / 3;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// SPORT_CONFIG — pipeline / resolution layer.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const ESPN_BASE = 'https://site.api.espn.com/apis/site/v2/sports';
|
||||
|
||||
// NBA / WNBA / NCAAB share the same basketball stat layout — index-based
|
||||
// athletes[].stats array. We define the map once and reuse it; the espn
|
||||
// URLs differ.
|
||||
const BASKETBALL_STAT_MAP = {
|
||||
// idx values follow the order ESPN returns in the basketball box-score
|
||||
// statistics[0].labels array: ['MIN','FG','3PT','FT','OREB','DREB','REB','AST','STL','BLK','TO','PF','+/-','PTS']
|
||||
// …but historically points was at idx 1 and rebounds at 5 — keep the
|
||||
// tested indices verified against tests/fixtures live samples.
|
||||
minutes: { idx: 0, parse: numOrZero },
|
||||
points: { idx: 1, parse: numOrZero },
|
||||
field_goals: { idx: 2, parse: splitMakes },
|
||||
threes_made: { idx: 3, parse: splitMakes },
|
||||
free_throws: { idx: 4, parse: splitMakes },
|
||||
rebounds: { idx: 5, parse: numOrZero },
|
||||
assists: { idx: 6, parse: numOrZero },
|
||||
turnovers: { idx: 7, parse: numOrZero },
|
||||
steals: { idx: 8, parse: numOrZero },
|
||||
blocks: { idx: 9, parse: numOrZero },
|
||||
pts_reb_ast: { calc: (s) => numOrZero(s?.[1]) + numOrZero(s?.[5]) + numOrZero(s?.[6]) },
|
||||
pts_reb: { calc: (s) => numOrZero(s?.[1]) + numOrZero(s?.[5]) },
|
||||
pts_ast: { calc: (s) => numOrZero(s?.[1]) + numOrZero(s?.[6]) },
|
||||
reb_ast: { calc: (s) => numOrZero(s?.[5]) + numOrZero(s?.[6]) },
|
||||
stl_blk: { calc: (s) => numOrZero(s?.[8]) + numOrZero(s?.[9]) },
|
||||
};
|
||||
|
||||
const MLB_STAT_MAP = {
|
||||
totalBases: { mlbField: 'totalBases' },
|
||||
strikeOuts: { mlbField: 'strikeOuts' },
|
||||
hits: { mlbField: 'hits' },
|
||||
homeRuns: { mlbField: 'homeRuns' },
|
||||
rbi: { mlbField: 'rbi' },
|
||||
stolenBases: { mlbField: 'stolenBases' },
|
||||
earnedRuns: { mlbField: 'earnedRuns' },
|
||||
inningsPitched: { mlbField: 'inningsPitched', parse: inningsToDecimal },
|
||||
runs: { mlbField: 'runs' },
|
||||
baseOnBalls: { mlbField: 'baseOnBalls' },
|
||||
hits_runs_rbi: { mlbCalc: (s) => numOrZero(s?.hits) + numOrZero(s?.runs) + numOrZero(s?.rbi) },
|
||||
};
|
||||
|
||||
const FOOTBALL_STAT_MAP = {
|
||||
passing_yards: { category: 'passing', field: 'passingYards' },
|
||||
passing_tds: { category: 'passing', field: 'passingTouchdowns' },
|
||||
interceptions: { category: 'passing', field: 'interceptions' },
|
||||
completions: { category: 'passing', field: 'completions' },
|
||||
rushing_yards: { category: 'rushing', field: 'rushingYards' },
|
||||
rushing_tds: { category: 'rushing', field: 'rushingTouchdowns' },
|
||||
receiving_yards: { category: 'receiving', field: 'receivingYards' },
|
||||
receptions: { category: 'receiving', field: 'receptions' },
|
||||
receiving_tds: { category: 'receiving', field: 'receivingTouchdowns' },
|
||||
};
|
||||
|
||||
const NHL_STAT_MAP = {
|
||||
goals: { field: 'goals' },
|
||||
assists: { field: 'assists' },
|
||||
shots: { field: 'shots' },
|
||||
saves: { field: 'saves' },
|
||||
points: { calc: (s) => numOrZero(s?.goals) + numOrZero(s?.assists) },
|
||||
};
|
||||
|
||||
const SPORT_CONFIG = Object.freeze({
|
||||
nba: Object.freeze({
|
||||
key: 'nba',
|
||||
label: 'NBA',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/basketball/nba/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/basketball/nba/summary`,
|
||||
gameStartHourET: 18,
|
||||
gameEndHourET: 24,
|
||||
statMap: BASKETBALL_STAT_MAP,
|
||||
}),
|
||||
wnba: Object.freeze({
|
||||
key: 'wnba',
|
||||
label: 'WNBA',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/basketball/wnba/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/basketball/wnba/summary`,
|
||||
gameStartHourET: 18,
|
||||
gameEndHourET: 24,
|
||||
statMap: BASKETBALL_STAT_MAP,
|
||||
}),
|
||||
ncaab: Object.freeze({
|
||||
key: 'ncaab',
|
||||
label: 'NCAAB',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/basketball/mens-college-basketball/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/basketball/mens-college-basketball/summary`,
|
||||
gameStartHourET: 18,
|
||||
gameEndHourET: 25, // late games on the west coast often roll past midnight
|
||||
statMap: BASKETBALL_STAT_MAP,
|
||||
}),
|
||||
mlb: Object.freeze({
|
||||
key: 'mlb',
|
||||
label: 'MLB',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/baseball/mlb/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/baseball/mlb/summary`,
|
||||
// MLB resolution reads from the MLB Stats API (richer + more reliable
|
||||
// than ESPN's MLB box scores), but tip-off detection still rides on
|
||||
// ESPN's scoreboard.
|
||||
useMlbStatsApi: true,
|
||||
mlbStatsApiBase: 'https://statsapi.mlb.com/api/v1.1',
|
||||
gameStartHourET: 13,
|
||||
gameEndHourET: 25,
|
||||
statMap: MLB_STAT_MAP,
|
||||
}),
|
||||
nfl: Object.freeze({
|
||||
key: 'nfl',
|
||||
label: 'NFL',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/football/nfl/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/football/nfl/summary`,
|
||||
gameStartHourET: 13,
|
||||
gameEndHourET: 24,
|
||||
statMap: FOOTBALL_STAT_MAP,
|
||||
}),
|
||||
ncaafb: Object.freeze({
|
||||
key: 'ncaafb',
|
||||
label: 'NCAA Football',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/football/college-football/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/football/college-football/summary`,
|
||||
gameStartHourET: 12,
|
||||
gameEndHourET: 25,
|
||||
statMap: FOOTBALL_STAT_MAP,
|
||||
}),
|
||||
nhl: Object.freeze({
|
||||
key: 'nhl',
|
||||
label: 'NHL',
|
||||
active: true,
|
||||
espnScoreboard: `${ESPN_BASE}/hockey/nhl/scoreboard`,
|
||||
espnSummary: `${ESPN_BASE}/hockey/nhl/summary`,
|
||||
gameStartHourET: 19,
|
||||
gameEndHourET: 24,
|
||||
statMap: NHL_STAT_MAP,
|
||||
}),
|
||||
});
|
||||
|
||||
function getActiveSports() {
|
||||
return Object.values(SPORT_CONFIG).filter((s) => s.active);
|
||||
}
|
||||
|
||||
function getSportConfig(sport) {
|
||||
const cfg = SPORT_CONFIG[String(sport || '').toLowerCase()];
|
||||
if (!cfg) throw new Error(`Unknown sport: ${sport}`);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// Legacy UI surface
|
||||
SPORTS,
|
||||
ALL,
|
||||
ACTIVE,
|
||||
COLLECTING,
|
||||
isActiveSport,
|
||||
shouldCollect,
|
||||
// Pipeline / resolution surface
|
||||
SPORT_CONFIG,
|
||||
getActiveSports,
|
||||
getSportConfig,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
const FOUNDER_NOTE = `
|
||||
VYNDR is a bet on advancement.
|
||||
A bet on intelligence.
|
||||
|
||||
The rooms our people are not usually in
|
||||
will be full and prepared when they arrive.
|
||||
|
||||
The tools to win have always existed.
|
||||
They just were not built for us.
|
||||
|
||||
Most apps were built to keep you engaged,
|
||||
not to keep you winning.
|
||||
There is a difference.
|
||||
|
||||
We look where nobody else looks
|
||||
because that is where the edge lives.
|
||||
We built the tools that syndicates keep private
|
||||
and we put them where you can reach them.
|
||||
|
||||
A multi-trillion dollar industry
|
||||
flows one direction.
|
||||
We built this to reverse that flow.
|
||||
|
||||
Bet on Black. Bet on intelligence. Bet on us.
|
||||
Every grade we have ever made is on this page.
|
||||
Every result is here.
|
||||
Draw your own conclusions.
|
||||
`;
|
||||
|
||||
module.exports = { FOUNDER_NOTE };
|
||||
@@ -0,0 +1,44 @@
|
||||
const MLB_PARKS = {
|
||||
yankee_stadium: { name: 'Yankee Stadium', coords: [40.8296, -73.9262], team: 'NYY' },
|
||||
fenway_park: { name: 'Fenway Park', coords: [42.3467, -71.0972], team: 'BOS' },
|
||||
dodger_stadium: { name: 'Dodger Stadium', coords: [34.0739, -118.2400], team: 'LAD' },
|
||||
wrigley_field: { name: 'Wrigley Field', coords: [41.9484, -87.6553], team: 'CHC' },
|
||||
oracle_park: { name: 'Oracle Park', coords: [37.7786, -122.3893], team: 'SF' },
|
||||
coors_field: { name: 'Coors Field', coords: [39.7559, -104.9942], team: 'COL' },
|
||||
petco_park: { name: 'Petco Park', coords: [32.7076, -117.1570], team: 'SD' },
|
||||
camden_yards: { name: 'Oriole Park at Camden Yards', coords: [39.2839, -76.6218], team: 'BAL' },
|
||||
comerica_park: { name: 'Comerica Park', coords: [42.3390, -83.0485], team: 'DET' },
|
||||
great_american: { name: 'Great American Ball Park', coords: [39.0976, -84.5082], team: 'CIN' },
|
||||
truist_park: { name: 'Truist Park', coords: [33.8908, -84.4678], team: 'ATL' },
|
||||
chase_field: { name: 'Chase Field', coords: [33.4453, -112.0667], team: 'ARI' },
|
||||
citi_field: { name: 'Citi Field', coords: [40.7571, -73.8458], team: 'NYM' },
|
||||
citizens_bank: { name: 'Citizens Bank Park', coords: [39.9061, -75.1665], team: 'PHI' },
|
||||
pnc_park: { name: 'PNC Park', coords: [40.4469, -80.0057], team: 'PIT' },
|
||||
busch_stadium: { name: 'Busch Stadium', coords: [38.6226, -90.1928], team: 'STL' },
|
||||
american_family: { name: 'American Family Field', coords: [43.0280, -87.9712], team: 'MIL' },
|
||||
progressive_field: { name: 'Progressive Field', coords: [41.4962, -81.6852], team: 'CLE' },
|
||||
minute_maid: { name: 'Minute Maid Park', coords: [29.7573, -95.3555], team: 'HOU' },
|
||||
globe_life: { name: 'Globe Life Field', coords: [32.7473, -97.0820], team: 'TEX' },
|
||||
angel_stadium: { name: 'Angel Stadium', coords: [33.8003, -117.8827], team: 'LAA' },
|
||||
t_mobile_park: { name: 'T-Mobile Park', coords: [47.5914, -122.3325], team: 'SEA' },
|
||||
target_field: { name: 'Target Field', coords: [44.9817, -93.2778], team: 'MIN' },
|
||||
kauffman_stadium: { name: 'Kauffman Stadium', coords: [39.0517, -94.4803], team: 'KC' },
|
||||
guaranteed_rate: { name: 'Guaranteed Rate Field', coords: [41.8300, -87.6339], team: 'CWS' },
|
||||
loanDepot_park: { name: 'loanDepot park', coords: [25.7781, -80.2197], team: 'MIA' },
|
||||
nationals_park: { name: 'Nationals Park', coords: [38.8730, -77.0074], team: 'WSH' },
|
||||
tropicana_field: { name: 'Tropicana Field', coords: [27.7683, -82.6534], team: 'TB' },
|
||||
oakland_coliseum: { name: 'Oakland Coliseum', coords: [37.7516, -122.2005], team: 'OAK' },
|
||||
rogers_centre: { name: 'Rogers Centre', coords: [43.6414, -79.3894], team: 'TOR' },
|
||||
};
|
||||
|
||||
function getParkByTeam(teamAbbrev) {
|
||||
const entries = Object.entries(MLB_PARKS);
|
||||
for (const [key, park] of entries) {
|
||||
if (park.team === teamAbbrev) {
|
||||
return { key, ...park };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { MLB_PARKS, getParkByTeam };
|
||||
@@ -0,0 +1,5 @@
|
||||
function missionHeader(req, res, next) {
|
||||
res.setHeader('X-VYNDR-Mission', 'bet-on-intelligence');
|
||||
next();
|
||||
}
|
||||
module.exports = { missionHeader };
|
||||
@@ -15,7 +15,7 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
const result = await getAlertsForUser(req.user.id);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Alerts error:', err.message);
|
||||
console.error('[VYNDR] Alerts error:', err.message);
|
||||
return res.status(503).json({ error: 'Alerts temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
@@ -32,7 +32,7 @@ router.patch('/:id/read', requireAuth, async (req, res) => {
|
||||
}
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Alert update error:', err.message);
|
||||
console.error('[VYNDR] Alert update error:', err.message);
|
||||
return res.status(503).json({ error: 'Alert update failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ router.post('/prop', async (req, res) => {
|
||||
if (err.statusCode === 429 || err.statusCode === 503) {
|
||||
return res.status(err.statusCode).json({ error: err.message });
|
||||
}
|
||||
console.error('[BetonBLK] Analysis error:', err.message);
|
||||
console.error('[VYNDR] Analysis error:', err.message);
|
||||
return res.status(503).json({ error: 'Analysis service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
+6
-6
@@ -35,7 +35,7 @@ router.post('/quickslip', requireAuth, async (req, res) => {
|
||||
return res.status(201).json(result);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) return res.status(404).json({ error: err.message });
|
||||
console.error('[BetonBLK] Quickslip error:', err.message);
|
||||
console.error('[VYNDR] Quickslip error:', err.message);
|
||||
return res.status(503).json({ error: 'Bet submission failed' });
|
||||
}
|
||||
});
|
||||
@@ -68,7 +68,7 @@ router.post('/screenshot/confirm', requireAuth, async (req, res) => {
|
||||
return res.status(201).json(result);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) return res.status(404).json({ error: err.message });
|
||||
console.error('[BetonBLK] Screenshot confirm error:', err.message);
|
||||
console.error('[VYNDR] Screenshot confirm error:', err.message);
|
||||
return res.status(503).json({ error: 'Bet submission failed' });
|
||||
}
|
||||
});
|
||||
@@ -78,7 +78,7 @@ router.post('/sync', requireAuth, async (req, res) => {
|
||||
return res.json({
|
||||
status: 'coming_soon',
|
||||
message: 'Sportsbook sync is coming soon. Use quick slip or screenshot for now.',
|
||||
supported_books: ['draftkings', 'fanduel', 'betmgm'],
|
||||
supported_books: ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ router.patch('/:id/settle', requireAuth, async (req, res) => {
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) return res.status(404).json({ error: err.message });
|
||||
if (err.statusCode === 422) return res.status(422).json({ error: err.message });
|
||||
console.error('[BetonBLK] Settle error:', err.message);
|
||||
console.error('[VYNDR] Settle error:', err.message);
|
||||
return res.status(503).json({ error: 'Settlement failed' });
|
||||
}
|
||||
});
|
||||
@@ -112,7 +112,7 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] List bets error:', err.message);
|
||||
console.error('[VYNDR] List bets error:', err.message);
|
||||
return res.status(503).json({ error: 'Failed to fetch bets' });
|
||||
}
|
||||
});
|
||||
@@ -123,7 +123,7 @@ router.get('/performance', requireAuth, async (req, res) => {
|
||||
const result = await recalculatePerformance(req.user.id);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Performance error:', err.message);
|
||||
console.error('[VYNDR] Performance error:', err.message);
|
||||
return res.status(503).json({ error: 'Failed to calculate performance' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Morning correction sweep.
|
||||
*
|
||||
* POST /api/grading/correct → re-checks recently resolved props against
|
||||
* the current ESPN box score.
|
||||
*
|
||||
* ESPN occasionally corrects late stat lines (an awarded steal becomes a
|
||||
* turnover the next morning, a rebound gets retroactively credited to a
|
||||
* different player). The sweep groups by game_id so we make ONE API call
|
||||
* per game, not per prop.
|
||||
*
|
||||
* Distribution: result flips go to Telegram. Push is intentionally NOT
|
||||
* fired — getting a "your prop hit … actually missed" notification 12
|
||||
* hours after the fact is confusing UX. Telegram is for the operator log.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
const { getSportConfig } = require('../config/sports');
|
||||
const { createLimiter, API_BUDGETS } = require('../utils/rateLimiter');
|
||||
const telegram = require('../services/distribution/telegram');
|
||||
const { __helpers: gradingHelpers } = require('./grading');
|
||||
|
||||
const router = express.Router();
|
||||
const espnLimiter = createLimiter(API_BUDGETS.espn);
|
||||
|
||||
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
function requireInternal(req, res, next) {
|
||||
const expected = process.env.VYNDR_INTERNAL_KEY;
|
||||
if (!expected) return res.status(503).json({ error: 'Internal auth not configured' });
|
||||
if (req.get('X-VYNDR-Internal-Key') !== expected) {
|
||||
return res.status(401).json({ error: 'Invalid internal key' });
|
||||
}
|
||||
if (!LOOPBACK_IPS.has(req.ip || req.socket?.remoteAddress)) {
|
||||
return res.status(403).json({ error: 'Origin not permitted' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
async function fetchBoxScore(sportCfg, gameId) {
|
||||
await espnLimiter.waitForToken();
|
||||
if (sportCfg.useMlbStatsApi) {
|
||||
const res = await axios.get(`${sportCfg.mlbStatsApiBase}/game/${gameId}/feed/live`, { timeout: 15_000 });
|
||||
return res.data;
|
||||
}
|
||||
const res = await axios.get(`${sportCfg.espnSummary}?event=${encodeURIComponent(gameId)}`, { timeout: 15_000 });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
router.post('/correct', requireInternal, async (req, res) => {
|
||||
const hours = Number(req.body?.hours) || 72;
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// Pull every resolved prop in the window. Pre-corrected ones (already
|
||||
// carrying a correction_note) are still re-checked — ESPN can correct a
|
||||
// correction.
|
||||
const { data: rows, error } = await supabase
|
||||
.from('resolution_results')
|
||||
.select('id, grade_id, game_id, sport, player_espn_id, player_name, stat_type, line, direction, actual_value, result, margin')
|
||||
.gte('resolved_at', cutoff);
|
||||
if (error) {
|
||||
console.error('[VYNDR] correction lookup failed:', error.message);
|
||||
return res.status(503).json({ error: 'Correction lookup failed' });
|
||||
}
|
||||
if (!rows || rows.length === 0) {
|
||||
return res.json({ checked: 0, corrected: 0, details: [] });
|
||||
}
|
||||
|
||||
// Group by game so we hit ESPN once per game instead of once per prop.
|
||||
const byGame = new Map();
|
||||
for (const r of rows) {
|
||||
const key = `${r.sport}:${r.game_id}`;
|
||||
if (!byGame.has(key)) byGame.set(key, []);
|
||||
byGame.get(key).push(r);
|
||||
}
|
||||
|
||||
let checked = 0;
|
||||
let corrected = 0;
|
||||
const details = [];
|
||||
|
||||
for (const [, propsForGame] of byGame.entries()) {
|
||||
const sport = propsForGame[0].sport;
|
||||
const gameId = propsForGame[0].game_id;
|
||||
let sportCfg;
|
||||
try { sportCfg = getSportConfig(sport); }
|
||||
catch { continue; }
|
||||
|
||||
let boxScore;
|
||||
try { boxScore = await fetchBoxScore(sportCfg, gameId); }
|
||||
catch (err) {
|
||||
console.warn(`[VYNDR] correction box-score fetch failed for ${gameId}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const idx = gradingHelpers.indexBoxScore(sport, boxScore);
|
||||
|
||||
for (const prop of propsForGame) {
|
||||
checked += 1;
|
||||
const found = idx.get(String(prop.player_espn_id));
|
||||
if (!found) continue;
|
||||
const newActual = gradingHelpers.calculateStat(found.statsBag, prop.stat_type, sportCfg);
|
||||
if (newActual == null) continue;
|
||||
|
||||
const newMargin = newActual - Number(prop.line);
|
||||
let newResult;
|
||||
if (prop.direction === 'over') {
|
||||
if (newActual > prop.line) newResult = 'hit';
|
||||
else if (newActual < prop.line) newResult = 'miss';
|
||||
else newResult = 'push';
|
||||
} else {
|
||||
if (newActual < prop.line) newResult = 'hit';
|
||||
else if (newActual > prop.line) newResult = 'miss';
|
||||
else newResult = 'push';
|
||||
}
|
||||
|
||||
// Only act if the value actually changed. Identical replays are no-ops.
|
||||
const valueChanged = Number(prop.actual_value) !== newActual;
|
||||
if (!valueChanged) continue;
|
||||
|
||||
const flipped = prop.result !== newResult;
|
||||
const patch = {
|
||||
actual_value: newActual,
|
||||
margin: newMargin,
|
||||
};
|
||||
if (flipped) {
|
||||
patch.result = newResult;
|
||||
patch.correction_note = `Corrected ${prop.result} → ${newResult}`;
|
||||
patch.correction_original_value = prop.actual_value;
|
||||
patch.correction_original_result = prop.result;
|
||||
}
|
||||
|
||||
await supabase.from('resolution_results').update(patch).eq('id', prop.id);
|
||||
await supabase.from('grade_history').update({
|
||||
actual_value: newActual,
|
||||
result: flipped ? newResult : prop.result,
|
||||
margin: newMargin,
|
||||
...(flipped ? {
|
||||
correction_note: patch.correction_note,
|
||||
correction_original_value: patch.correction_original_value,
|
||||
correction_original_result: patch.correction_original_result,
|
||||
} : {}),
|
||||
}).eq('id', prop.grade_id);
|
||||
|
||||
details.push({
|
||||
grade_id: prop.grade_id,
|
||||
player_name: prop.player_name,
|
||||
stat_type: prop.stat_type,
|
||||
old: { actual: prop.actual_value, result: prop.result },
|
||||
new: { actual: newActual, result: newResult },
|
||||
flipped,
|
||||
});
|
||||
if (flipped) corrected += 1;
|
||||
|
||||
if (flipped && telegram.configured?.()) {
|
||||
telegram.postToTelegram({
|
||||
text: `🔄 CORRECTION | ${prop.player_name} ${prop.direction} ${prop.line} ${prop.stat_type} | ${prop.actual_value}→${newActual} | ${prop.result.toUpperCase()}→${newResult.toUpperCase()}`,
|
||||
}).catch(() => { /* fire-and-forget */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ checked, corrected, details });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* Resolution + correction endpoints.
|
||||
*
|
||||
* POST /api/grading/resolve — called by the ESPN poller at FINAL
|
||||
* POST /api/grading/correct — called by the morning sweep (Section 8)
|
||||
*
|
||||
* Auth model:
|
||||
* - X-VYNDR-Internal-Key header must match VYNDR_INTERNAL_KEY
|
||||
* - Source IP must be loopback (127.0.0.1 / ::1 / ::ffff:127.0.0.1)
|
||||
* Two-factor "in-cluster only": even if the key leaks, an attacker still
|
||||
* needs a foothold inside Docker's network. PM2 pollers and the morning
|
||||
* cron run from the same host, so this is fine in practice.
|
||||
*
|
||||
* Service-role Supabase: we read & write across every user's grade_history
|
||||
* for the game, bypassing RLS intentionally.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
const { getSportConfig } = require('../config/sports');
|
||||
const { logResolution } = require('../services/training/jsonlLogger');
|
||||
const webPush = require('../services/distribution/webPush');
|
||||
const telegram = require('../services/distribution/telegram');
|
||||
const discord = require('../services/distribution/discord');
|
||||
const clvTracker = require('../services/intelligence/clvTracker');
|
||||
const accuracyTracker = require('../services/intelligence/accuracyTracker');
|
||||
const weightAdjuster = require('../services/intelligence/weightAdjuster');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
function requireInternal(req, res, next) {
|
||||
const expected = process.env.VYNDR_INTERNAL_KEY;
|
||||
if (!expected) {
|
||||
// Refuse to serve if the secret isn't configured — better than
|
||||
// accidentally exposing the endpoint with a default value.
|
||||
return res.status(503).json({ error: 'Internal auth not configured' });
|
||||
}
|
||||
const provided = req.get('X-VYNDR-Internal-Key');
|
||||
if (!provided || provided !== expected) {
|
||||
return res.status(401).json({ error: 'Invalid internal key' });
|
||||
}
|
||||
const remoteIp = req.ip || req.socket?.remoteAddress;
|
||||
if (!LOOPBACK_IPS.has(remoteIp)) {
|
||||
return res.status(403).json({ error: 'Origin not permitted' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Box-score traversal — sport-specific shapes flattened into a uniform
|
||||
// { playerEspnId, statsBag } pair so downstream calculateStat doesn't
|
||||
// need to know which sport produced the box.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
function indexBasketballBox(boxScore) {
|
||||
// Two teams, each with statistics[0].athletes[]
|
||||
const out = new Map();
|
||||
for (const team of boxScore?.boxscore?.players || []) {
|
||||
const stats = team?.statistics?.[0];
|
||||
if (!stats?.athletes) continue;
|
||||
for (const a of stats.athletes) {
|
||||
const id = a?.athlete?.id || a?.id;
|
||||
if (!id) continue;
|
||||
out.set(String(id), {
|
||||
statsBag: a.stats || [],
|
||||
starter: !!a.starter,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function indexMlbBox(boxScore) {
|
||||
const out = new Map();
|
||||
const teams = boxScore?.liveData?.boxscore?.teams || {};
|
||||
for (const side of ['home', 'away']) {
|
||||
const players = teams[side]?.players || {};
|
||||
for (const key of Object.keys(players)) {
|
||||
const p = players[key];
|
||||
// MLB Stats API "person" carries a numeric ID. The player_id_map's
|
||||
// mlbam_id column is matched to this for ESPN-graded props.
|
||||
const id = p?.person?.id;
|
||||
if (!id) continue;
|
||||
// Aggregate batting + pitching stats into a single bag.
|
||||
out.set(String(id), {
|
||||
statsBag: { ...(p.stats?.batting || {}), ...(p.stats?.pitching || {}) },
|
||||
starter: !!p.gameStatus?.isCurrentBatter,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function indexFootballBox(boxScore) {
|
||||
// statistics is an array of categories: passing, rushing, receiving, …
|
||||
const out = new Map();
|
||||
for (const team of boxScore?.boxscore?.players || []) {
|
||||
const cats = team?.statistics || [];
|
||||
if (!Array.isArray(cats)) continue;
|
||||
for (const cat of cats) {
|
||||
for (const a of cat?.athletes || []) {
|
||||
const id = a?.athlete?.id || a?.id;
|
||||
if (!id) continue;
|
||||
const existing = out.get(String(id)) || { statsBag: {}, starter: !!a.starter };
|
||||
// Each category exposes a different stat label ordering; build a
|
||||
// named map per category from labels[] × stats[].
|
||||
const labels = cat?.labels || cat?.keys || [];
|
||||
const stats = a?.stats || [];
|
||||
const named = {};
|
||||
labels.forEach((label, i) => { named[label] = stats[i]; });
|
||||
existing.statsBag[cat.name || cat.text] = named;
|
||||
out.set(String(id), existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function indexNhlBox(boxScore) {
|
||||
// ESPN NHL surfaces statistics as a labels/stats pair per athlete.
|
||||
const out = new Map();
|
||||
for (const team of boxScore?.boxscore?.players || []) {
|
||||
for (const cat of team?.statistics || []) {
|
||||
const labels = cat?.labels || [];
|
||||
for (const a of cat?.athletes || []) {
|
||||
const id = a?.athlete?.id || a?.id;
|
||||
if (!id) continue;
|
||||
const stats = a?.stats || [];
|
||||
const named = {};
|
||||
labels.forEach((label, i) => { named[label.toLowerCase()] = Number(stats[i]) || 0; });
|
||||
const existing = out.get(String(id)) || { statsBag: {}, starter: !!a.starter };
|
||||
Object.assign(existing.statsBag, named);
|
||||
out.set(String(id), existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function indexBoxScore(sport, boxScore) {
|
||||
switch (sport) {
|
||||
case 'nba':
|
||||
case 'wnba':
|
||||
case 'ncaab':
|
||||
return indexBasketballBox(boxScore);
|
||||
case 'nfl':
|
||||
case 'ncaafb':
|
||||
return indexFootballBox(boxScore);
|
||||
case 'mlb':
|
||||
return indexMlbBox(boxScore);
|
||||
case 'nhl':
|
||||
return indexNhlBox(boxScore);
|
||||
default:
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// calculateStat: pick the parse strategy based on which key the statMap
|
||||
// entry carries. Format pecking order matches the four documented styles
|
||||
// in src/config/sports.js.
|
||||
function calculateStat(statsBag, statType, sportCfg) {
|
||||
const map = sportCfg.statMap?.[statType];
|
||||
if (!map) return null;
|
||||
if (map.calc) return Number(map.calc(statsBag)) || 0;
|
||||
if (map.mlbCalc) return Number(map.mlbCalc(statsBag)) || 0;
|
||||
if (map.mlbField) {
|
||||
const raw = statsBag?.[map.mlbField];
|
||||
return map.parse ? map.parse(raw) : (Number(raw) || 0);
|
||||
}
|
||||
if (map.category) {
|
||||
const cat = statsBag?.[map.category];
|
||||
if (!cat) return 0;
|
||||
return Number(cat[map.field]) || 0;
|
||||
}
|
||||
if (map.idx !== undefined) {
|
||||
const raw = statsBag?.[map.idx];
|
||||
if (raw === undefined || raw === null || raw === '') return 0;
|
||||
return map.parse ? map.parse(raw) : (Number(raw) || 0);
|
||||
}
|
||||
if (map.field) return Number(statsBag?.[map.field]) || 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function emoji(result) {
|
||||
return { hit: '✅', miss: '❌', push: '➡️', void: '⚪' }[result] || '•';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Resolution endpoint
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleVoidGame(supabase, gameId, reason) {
|
||||
// Mark every unresolved prop for this game as void with the reason.
|
||||
const { data: rows, error: lookupErr } = await supabase
|
||||
.from('grade_history')
|
||||
.select('id, player_id, player_name, stat_type, line, direction, grade, sport')
|
||||
.eq('game_id', gameId)
|
||||
.is('resolved_at', null);
|
||||
if (lookupErr) throw lookupErr;
|
||||
if (!rows || rows.length === 0) return { resolved: 0, voided: 0, results: [] };
|
||||
const nowIso = new Date().toISOString();
|
||||
const ids = rows.map((r) => r.id);
|
||||
await supabase
|
||||
.from('grade_history')
|
||||
.update({ result: 'void', resolved_at: nowIso, correction_note: reason })
|
||||
.in('id', ids);
|
||||
return { resolved: 0, voided: rows.length, results: rows.map((r) => ({ ...r, result: 'void' })) };
|
||||
}
|
||||
|
||||
router.post('/resolve', requireInternal, async (req, res) => {
|
||||
const { gameId, sport, boxScore, void: isVoid, reason } = req.body || {};
|
||||
if (!gameId || !sport) {
|
||||
return res.status(400).json({ error: 'gameId and sport are required' });
|
||||
}
|
||||
let sportCfg;
|
||||
try { sportCfg = getSportConfig(sport); }
|
||||
catch (err) { return res.status(400).json({ error: err.message }); }
|
||||
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
if (isVoid) {
|
||||
try {
|
||||
const summary = await handleVoidGame(supabase, gameId, reason || 'void');
|
||||
return res.json(summary);
|
||||
} catch (err) {
|
||||
console.error('[VYNDR] Void resolution error:', err.message);
|
||||
return res.status(503).json({ error: 'Void processing failed' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!boxScore) return res.status(400).json({ error: 'boxScore required (or void: true)' });
|
||||
|
||||
// 1. Pull unresolved props for this game.
|
||||
const { data: unresolved, error: lookupErr } = await supabase
|
||||
.from('grade_history')
|
||||
.select('id, player_id, player_name, stat_type, line, direction, grade, sport, projection, closing_line_id, factors')
|
||||
.eq('game_id', gameId)
|
||||
.is('resolved_at', null);
|
||||
if (lookupErr) {
|
||||
console.error('[VYNDR] grade_history lookup error:', lookupErr.message);
|
||||
return res.status(503).json({ error: 'Resolution lookup failed' });
|
||||
}
|
||||
if (!unresolved || unresolved.length === 0) {
|
||||
return res.json({ resolved: 0, voided: 0, results: [] });
|
||||
}
|
||||
|
||||
// 2. Walk the box score into a per-player stats bag.
|
||||
const boxIndex = indexBoxScore(sport, boxScore);
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const updates = []; // { id, patch } per prop
|
||||
const resolutionRows = []; // resolution_results inserts
|
||||
const results = []; // response payload
|
||||
|
||||
for (const prop of unresolved) {
|
||||
const playerEspnId = String(prop.player_id);
|
||||
const found = boxIndex.get(playerEspnId);
|
||||
|
||||
if (!found) {
|
||||
const patch = {
|
||||
result: 'void',
|
||||
resolved_at: nowIso,
|
||||
correction_note: 'DNP - Player not in box score',
|
||||
};
|
||||
updates.push({ id: prop.id, patch });
|
||||
resolutionRows.push({
|
||||
grade_id: prop.id,
|
||||
game_id: gameId,
|
||||
sport,
|
||||
player_espn_id: playerEspnId,
|
||||
player_name: prop.player_name,
|
||||
stat_type: prop.stat_type,
|
||||
line: Number(prop.line),
|
||||
direction: prop.direction,
|
||||
actual_value: 0,
|
||||
result: 'void',
|
||||
margin: null,
|
||||
correction_note: 'DNP',
|
||||
});
|
||||
results.push({ ...prop, result: 'void', actual_value: null });
|
||||
continue;
|
||||
}
|
||||
|
||||
const actual = calculateStat(found.statsBag, prop.stat_type, sportCfg);
|
||||
let result, margin;
|
||||
if (actual == null) {
|
||||
result = 'void';
|
||||
margin = null;
|
||||
} else {
|
||||
const line = Number(prop.line);
|
||||
margin = actual - line;
|
||||
if (prop.direction === 'over') {
|
||||
if (actual > line) result = 'hit';
|
||||
else if (actual < line) result = 'miss';
|
||||
else result = 'push';
|
||||
} else {
|
||||
if (actual < line) result = 'hit';
|
||||
else if (actual > line) result = 'miss';
|
||||
else result = 'push';
|
||||
}
|
||||
}
|
||||
|
||||
const actualNum = actual == null ? 0 : actual;
|
||||
updates.push({
|
||||
id: prop.id,
|
||||
patch: { result, actual_value: actualNum, margin, resolved_at: nowIso, was_starter: !!found.starter },
|
||||
});
|
||||
resolutionRows.push({
|
||||
grade_id: prop.id,
|
||||
game_id: gameId,
|
||||
sport,
|
||||
player_espn_id: playerEspnId,
|
||||
player_name: prop.player_name,
|
||||
stat_type: prop.stat_type,
|
||||
line: Number(prop.line),
|
||||
direction: prop.direction,
|
||||
actual_value: actualNum,
|
||||
result,
|
||||
margin,
|
||||
was_starter: !!found.starter,
|
||||
closing_line_id: prop.closing_line_id || null,
|
||||
});
|
||||
results.push({ ...prop, result, actual_value: actualNum, margin });
|
||||
}
|
||||
|
||||
// 3. Atomic-ish batch write — Supabase doesn't expose a true transaction,
|
||||
// but a single .insert / single .upsert is one round-trip.
|
||||
if (resolutionRows.length) {
|
||||
const { error: insertErr } = await supabase.from('resolution_results').insert(resolutionRows);
|
||||
if (insertErr) console.warn('[VYNDR] resolution_results insert error:', insertErr.message);
|
||||
}
|
||||
|
||||
// grade_history updates can't be batched (different patches per row), but
|
||||
// they're indexed by primary key so latency is bounded.
|
||||
for (const u of updates) {
|
||||
const { error: updErr } = await supabase.from('grade_history').update(u.patch).eq('id', u.id);
|
||||
if (updErr) console.warn('[VYNDR] grade_history update error:', updErr.message);
|
||||
}
|
||||
|
||||
// 4. Side effects — none can block the response or each other.
|
||||
const sideEffects = [];
|
||||
for (const r of results) {
|
||||
sideEffects.push(Promise.resolve().then(() => {
|
||||
logResolution({
|
||||
sport,
|
||||
player_espn_id: String(r.player_id),
|
||||
player_name: r.player_name,
|
||||
stat_type: r.stat_type,
|
||||
line: Number(r.line),
|
||||
direction: r.direction,
|
||||
actual_value: r.actual_value,
|
||||
result: r.result,
|
||||
margin: r.margin,
|
||||
grade: r.grade,
|
||||
});
|
||||
}));
|
||||
|
||||
if (webPush.configured() && r.result !== 'void') {
|
||||
sideEffects.push(webPush.sendPushToSport(sport, {
|
||||
title: 'VYNDR Grade Resolved',
|
||||
body: `${r.player_name} ${r.direction} ${r.line} ${r.stat_type}: ${(r.result || '').toUpperCase()} ${emoji(r.result)} (actual ${r.actual_value}, margin ${r.margin})`,
|
||||
url: '/ledger',
|
||||
}, { kind: 'resolution' }));
|
||||
}
|
||||
|
||||
if (telegram.configured?.()) {
|
||||
sideEffects.push(telegram.postToTelegram({
|
||||
text: `${emoji(r.result)} ${(r.result || '').toUpperCase()} | ${r.player_name} ${r.direction} ${r.line} ${r.stat_type} | Actual: ${r.actual_value} | Grade: ${r.grade}`,
|
||||
}));
|
||||
}
|
||||
if (discord.webhookFor?.('results')) {
|
||||
sideEffects.push(discord.postToDiscord('results', {
|
||||
text: `${(r.result || '').toUpperCase()} ${emoji(r.result)} — ${r.player_name} ${r.direction} ${r.line} ${r.stat_type} → ${r.actual_value} (margin ${r.margin}) — Grade ${r.grade}`,
|
||||
}));
|
||||
}
|
||||
|
||||
// Learning-loop hooks — all fire-and-forget. None can block the
|
||||
// response or each other. Failures inside any of these are logged
|
||||
// by the service itself and never propagate up.
|
||||
if (r.result === 'hit' || r.result === 'miss' || r.result === 'push' || r.result === 'void') {
|
||||
sideEffects.push(accuracyTracker.recordResolution(sport, r.grade, r.result));
|
||||
}
|
||||
if (r.result === 'hit' || r.result === 'miss') {
|
||||
sideEffects.push(clvTracker.computeCLV(r.id));
|
||||
sideEffects.push(weightAdjuster.adjustWeights({
|
||||
sport,
|
||||
stat_type: r.stat_type,
|
||||
grade: r.grade,
|
||||
result: r.result,
|
||||
factors: Array.isArray(r.factors) ? r.factors : [],
|
||||
grade_id: r.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Fire-and-forget; one failure can't block another.
|
||||
Promise.allSettled(sideEffects).catch(() => { /* swallowed */ });
|
||||
|
||||
const resolvedCount = results.filter((r) => r.result === 'hit' || r.result === 'miss' || r.result === 'push').length;
|
||||
const voidedCount = results.length - resolvedCount;
|
||||
|
||||
return res.json({ resolved: resolvedCount, voided: voidedCount, results });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// POST /pipeline — called by n8n (or curl) to run the grading pipeline
|
||||
// for one sport. Same auth as /resolve. The orchestrator handles all
|
||||
// upstream calls; we just wrap it in HTTP with input validation.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'ncaab', 'ncaafb']);
|
||||
|
||||
router.post('/pipeline', requireInternal, async (req, res) => {
|
||||
const { sport, options } = req.body || {};
|
||||
if (!sport || !VALID_SPORTS.has(sport)) {
|
||||
return res.status(400).json({ error: 'sport must be one of: nba, wnba, mlb, nfl, nhl, ncaab, ncaafb' });
|
||||
}
|
||||
// Lazy-load the orchestrator so this route doesn't pay the require cost
|
||||
// until it's actually invoked (and so unit tests of /resolve don't pull
|
||||
// in the whole adapter graph).
|
||||
const { runPipeline } = require('../services/intelligence/gradingOrchestrator');
|
||||
try {
|
||||
const summary = await runPipeline(sport, options || {});
|
||||
return res.json(summary);
|
||||
} catch (err) {
|
||||
console.error('[VYNDR] Pipeline error:', err.message);
|
||||
return res.status(503).json({ error: 'Pipeline run failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Exported so server.js can wire it up with a larger body limit; also lets
|
||||
// tests import the helper without binding to Express.
|
||||
module.exports = router;
|
||||
module.exports.__helpers = { calculateStat, indexBoxScore, requireInternal };
|
||||
@@ -19,7 +19,7 @@ router.get('/', async (req, res) => {
|
||||
movements,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Movements error:', err.message);
|
||||
console.error('[VYNDR] Movements error:', err.message);
|
||||
return res.status(503).json({ error: 'Movement data temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
+2
-2
@@ -86,7 +86,7 @@ router.get('/nba', async (req, res) => {
|
||||
const props = groupProps(filtered);
|
||||
|
||||
if (result.stale) {
|
||||
res.set('X-BetonBLK-Stale', 'true');
|
||||
res.set('X-VYNDR-Stale', 'true');
|
||||
}
|
||||
|
||||
const response = {
|
||||
@@ -131,7 +131,7 @@ router.get('/ncaab', async (req, res) => {
|
||||
const props = groupProps(filtered);
|
||||
|
||||
if (result.stale) {
|
||||
res.set('X-BetonBLK-Stale', 'true');
|
||||
res.set('X-VYNDR-Stale', 'true');
|
||||
}
|
||||
|
||||
return res.json({
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Pipeline routes — orchestrate the data pipeline.
|
||||
*
|
||||
* POST /api/pipeline/refresh body: { sport, graded? }
|
||||
* GET /api/pipeline/status
|
||||
*
|
||||
* Refresh is the only write path; it's the one n8n calls. We gate it with
|
||||
* a shared secret so a stray POST from the open internet can't trigger an
|
||||
* upstream fan-out.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const provider = require('../services/UnifiedOddsProvider');
|
||||
const { isActiveSport, shouldCollect, SPORTS } = require('../config/sports');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const SUPPORTED = Object.keys(SPORTS);
|
||||
|
||||
function requirePipelineSecret(req, res, next) {
|
||||
const expected = process.env.PIPELINE_SECRET;
|
||||
if (!expected) return res.status(503).json({ error: 'PIPELINE_SECRET not configured' });
|
||||
const got = req.get('X-Pipeline-Secret') || req.body?.secret;
|
||||
if (!got || got !== expected) {
|
||||
return res.status(401).json({ error: 'invalid pipeline secret' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
router.post('/refresh', requirePipelineSecret, async (req, res) => {
|
||||
const sport = String(req.body?.sport || '').toLowerCase();
|
||||
if (!sport || !SUPPORTED.includes(sport)) {
|
||||
return res.status(400).json({ error: 'invalid or missing sport', supported: SUPPORTED });
|
||||
}
|
||||
try {
|
||||
const out = await provider.fullRefresh(sport, {
|
||||
gradedProps: Array.isArray(req.body?.graded) ? req.body.graded : [],
|
||||
});
|
||||
return res.json(out);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: 'refresh failed', detail: err?.message || 'unknown' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status', async (_req, res) => {
|
||||
const sports = Object.values(SPORTS).map((s) => ({
|
||||
key: s.key,
|
||||
label: s.label,
|
||||
active: s.active,
|
||||
collect: s.collectData,
|
||||
}));
|
||||
return res.json({
|
||||
sports,
|
||||
runtime: provider.status(),
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,81 @@
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /joint-history — joint outcome history and phi coefficient
|
||||
router.get('/joint-history', requireAuth, async (req, res) => {
|
||||
const { player_a, stat_a, player_b, stat_b } = req.query;
|
||||
|
||||
// Block free tier
|
||||
if (!req.user.tier || req.user.tier === 'free') {
|
||||
return res.status(403).json({
|
||||
error: 'Joint history requires Analyst or Desk tier',
|
||||
upgrade_url: '/pricing',
|
||||
});
|
||||
}
|
||||
|
||||
if (!player_a || !stat_a || !player_b || !stat_b) {
|
||||
return res.status(400).json({
|
||||
error: 'Required query params: player_a, stat_a, player_b, stat_b',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('joint_outcomes')
|
||||
.select('*')
|
||||
.eq('player_a', player_a)
|
||||
.eq('stat_a', stat_a)
|
||||
.eq('player_b', player_b)
|
||||
.eq('stat_b', stat_b);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return res.json({
|
||||
player_a,
|
||||
stat_a,
|
||||
player_b,
|
||||
stat_b,
|
||||
sample_size: 0,
|
||||
phi_coefficient: null,
|
||||
outcomes: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate phi coefficient from joint outcomes
|
||||
let both_hit = 0, a_only = 0, b_only = 0, neither = 0;
|
||||
for (const row of data) {
|
||||
if (row.a_hit && row.b_hit) both_hit++;
|
||||
else if (row.a_hit && !row.b_hit) a_only++;
|
||||
else if (!row.a_hit && row.b_hit) b_only++;
|
||||
else neither++;
|
||||
}
|
||||
|
||||
const n = data.length;
|
||||
const num = (both_hit * neither) - (a_only * b_only);
|
||||
const denom = Math.sqrt(
|
||||
(both_hit + a_only) * (b_only + neither) *
|
||||
(both_hit + b_only) * (a_only + neither)
|
||||
);
|
||||
const phi = denom === 0 ? 0 : num / denom;
|
||||
|
||||
res.json({
|
||||
player_a,
|
||||
stat_a,
|
||||
player_b,
|
||||
stat_b,
|
||||
sample_size: n,
|
||||
phi_coefficient: Math.round(phi * 1000) / 1000,
|
||||
outcomes: data,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[props/joint-history]', err.message);
|
||||
res.status(503).json({ error: 'Service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Push subscription endpoints.
|
||||
*
|
||||
* POST /api/push/subscribe — register a new browser push endpoint
|
||||
* DELETE /api/push/unsubscribe — remove a subscription by endpoint
|
||||
*
|
||||
* Subscriptions are stored in push_subscriptions (migration 015) with RLS
|
||||
* gated to auth.uid() = user_id. We use the service role here so we don't
|
||||
* have to thread the user JWT through Supabase — requireAuth has already
|
||||
* verified the user.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function validSubscription(sub) {
|
||||
if (!sub || typeof sub !== 'object') return false;
|
||||
if (typeof sub.endpoint !== 'string' || !sub.endpoint.startsWith('https://')) return false;
|
||||
if (!sub.keys || typeof sub.keys !== 'object') return false;
|
||||
if (typeof sub.keys.p256dh !== 'string' || typeof sub.keys.auth !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
router.post('/subscribe', requireAuth, async (req, res) => {
|
||||
const { subscription, preferences } = req.body || {};
|
||||
if (!validSubscription(subscription)) {
|
||||
return res.status(400).json({ error: 'Invalid subscription payload' });
|
||||
}
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const row = {
|
||||
user_id: req.user.id,
|
||||
endpoint: subscription.endpoint,
|
||||
keys_p256dh: subscription.keys.p256dh,
|
||||
keys_auth: subscription.keys.auth,
|
||||
};
|
||||
if (Array.isArray(preferences?.sports)) row.sport_preferences = preferences.sports;
|
||||
if (typeof preferences?.notify_on_resolution === 'boolean') {
|
||||
row.notify_on_resolution = preferences.notify_on_resolution;
|
||||
}
|
||||
if (typeof preferences?.notify_on_cascade === 'boolean') {
|
||||
row.notify_on_cascade = preferences.notify_on_cascade;
|
||||
}
|
||||
if (typeof preferences?.notify_on_cheatsheet === 'boolean') {
|
||||
row.notify_on_cheatsheet = preferences.notify_on_cheatsheet;
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.upsert(row, { onConflict: 'user_id,endpoint' });
|
||||
if (error) {
|
||||
console.error('[VYNDR] Push subscribe error:', error.message);
|
||||
return res.status(503).json({ error: 'Subscription save failed' });
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[VYNDR] Push subscribe error:', err.message);
|
||||
return res.status(503).json({ error: 'Subscription save failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/unsubscribe', requireAuth, async (req, res) => {
|
||||
const { endpoint } = req.body || {};
|
||||
if (typeof endpoint !== 'string' || !endpoint.startsWith('https://')) {
|
||||
return res.status(400).json({ error: 'Invalid endpoint' });
|
||||
}
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { error } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.delete()
|
||||
.eq('user_id', req.user.id)
|
||||
.eq('endpoint', endpoint);
|
||||
if (error) {
|
||||
console.error('[VYNDR] Push unsubscribe error:', error.message);
|
||||
return res.status(503).json({ error: 'Unsubscribe failed' });
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[VYNDR] Push unsubscribe error:', err.message);
|
||||
return res.status(503).json({ error: 'Unsubscribe failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+1
-1
@@ -58,7 +58,7 @@ router.post('/parlay', requireAuth, async (req, res) => {
|
||||
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Scan error:', err.message);
|
||||
console.error('[VYNDR] Scan error:', err.message);
|
||||
return res.status(503).json({ error: 'Scan service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* POST /api/share-card — returns a PNG (or SVG fallback) for social sharing.
|
||||
*
|
||||
* Inputs are validated against allowlists. Any caller-supplied text is
|
||||
* XML-escaped in the renderer. Per-IP rate limit (in-memory) prevents
|
||||
* abuse. Hashed inputs back a tiny disk cache in /tmp/share-cards so
|
||||
* repeated requests serve from disk.
|
||||
*
|
||||
* SECURITY:
|
||||
* - Sport / grade / type / direction / format ∈ allowlist
|
||||
* - Player & stat & summary length-clamped
|
||||
* - No HTML, no SVG injection (renderer escapes everything)
|
||||
* - Rate limit: 30 cards / minute / IP
|
||||
* - Sharp is invoked through a memory-capped sharp() chain
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const renderer = require('../services/shareCards/renderer');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const VALID_FORMATS = new Set(['twitter', 'story', 'square']);
|
||||
const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'tennis', 'mma', 'boxing', 'golf']);
|
||||
const VALID_GRADES = new Set([
|
||||
'A+', 'A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D', 'F',
|
||||
]);
|
||||
const VALID_DIRECTIONS = new Set(['over', 'under']);
|
||||
const VALID_RESULTS = new Set(['hit', 'miss', 'push', 'pending']);
|
||||
const MAX_PLAYER_LEN = 64;
|
||||
const MAX_STAT_LEN = 32;
|
||||
const MAX_SUMMARY_LEN = 160;
|
||||
const MAX_RECAP_ENTRIES = 8;
|
||||
const MAX_CHEATSHEET_ENTRIES = 8;
|
||||
|
||||
const CACHE_DIR = path.join('/tmp', 'vyndr-share-cards');
|
||||
fs.mkdir(CACHE_DIR, { recursive: true }).catch(() => {});
|
||||
|
||||
// ── tiny in-memory rate limiter (per IP, 30/min sliding window) ───────────
|
||||
const RATE_WINDOW_MS = 60_000;
|
||||
const RATE_MAX = 30;
|
||||
const ipBuckets = new Map();
|
||||
|
||||
function checkRate(ip) {
|
||||
const now = Date.now();
|
||||
const arr = ipBuckets.get(ip) || [];
|
||||
const fresh = arr.filter((t) => now - t < RATE_WINDOW_MS);
|
||||
if (fresh.length >= RATE_MAX) return false;
|
||||
fresh.push(now);
|
||||
ipBuckets.set(ip, fresh);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Periodic prune so the map doesn't grow unbounded.
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, arr] of ipBuckets.entries()) {
|
||||
const fresh = arr.filter((t) => now - t < RATE_WINDOW_MS);
|
||||
if (fresh.length === 0) ipBuckets.delete(ip);
|
||||
else ipBuckets.set(ip, fresh);
|
||||
}
|
||||
}, RATE_WINDOW_MS).unref?.();
|
||||
|
||||
// ── input shaping & validation ────────────────────────────────────────────
|
||||
|
||||
function pickStr(v, max) {
|
||||
if (typeof v !== 'string') return null;
|
||||
const trimmed = v.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.slice(0, max);
|
||||
}
|
||||
|
||||
function pickNum(v) {
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function validateBase(body) {
|
||||
const errors = [];
|
||||
const type = pickStr(body.type, 16);
|
||||
if (!type || !renderer.VALID_TYPES.has(type)) errors.push('type must be one of: grade, victory, recap, cheatsheet, gotd');
|
||||
const format = pickStr(body.format, 16) || 'twitter';
|
||||
if (!VALID_FORMATS.has(format)) errors.push(`format must be one of: ${[...VALID_FORMATS].join(', ')}`);
|
||||
return { type, format, errors };
|
||||
}
|
||||
|
||||
function shapeSinglePropPayload(b) {
|
||||
return {
|
||||
player: pickStr(b.player, MAX_PLAYER_LEN),
|
||||
sport: (b.sport && VALID_SPORTS.has(String(b.sport).toLowerCase())) ? String(b.sport).toLowerCase() : null,
|
||||
stat: pickStr(b.stat, MAX_STAT_LEN),
|
||||
line: pickNum(b.line),
|
||||
direction: VALID_DIRECTIONS.has(String(b.direction || '').toLowerCase()) ? String(b.direction).toLowerCase() : 'over',
|
||||
grade: VALID_GRADES.has(String(b.grade || '').toUpperCase()) ? String(b.grade).toUpperCase() : null,
|
||||
projection: pickNum(b.projection),
|
||||
summary: pickStr(b.summary, MAX_SUMMARY_LEN),
|
||||
};
|
||||
}
|
||||
|
||||
function shapeRecapPayload(b) {
|
||||
const entries = Array.isArray(b.entries) ? b.entries.slice(0, MAX_RECAP_ENTRIES) : [];
|
||||
return {
|
||||
date: pickStr(b.date, 32),
|
||||
accuracy: pickNum(b.accuracy),
|
||||
entries: entries.map((e) => ({
|
||||
player: pickStr(e.player, MAX_PLAYER_LEN),
|
||||
stat: pickStr(e.stat, MAX_STAT_LEN),
|
||||
direction: VALID_DIRECTIONS.has(String(e.direction || '').toLowerCase()) ? String(e.direction).toLowerCase() : 'over',
|
||||
line: pickNum(e.line),
|
||||
grade: VALID_GRADES.has(String(e.grade || '').toUpperCase()) ? String(e.grade).toUpperCase() : null,
|
||||
result: VALID_RESULTS.has(String(e.result || '').toLowerCase()) ? String(e.result).toLowerCase() : 'pending',
|
||||
})).filter((e) => e.player && e.grade),
|
||||
};
|
||||
}
|
||||
|
||||
function shapeCheatsheetPayload(b) {
|
||||
const grades = Array.isArray(b.grades) ? b.grades.slice(0, MAX_CHEATSHEET_ENTRIES) : [];
|
||||
return {
|
||||
date: pickStr(b.date, 32),
|
||||
gameCount: pickNum(b.gameCount),
|
||||
grades: grades.map((g) => ({
|
||||
player: pickStr(g.player, MAX_PLAYER_LEN),
|
||||
stat: pickStr(g.stat, MAX_STAT_LEN),
|
||||
direction: VALID_DIRECTIONS.has(String(g.direction || '').toLowerCase()) ? String(g.direction).toLowerCase() : 'over',
|
||||
line: pickNum(g.line),
|
||||
grade: VALID_GRADES.has(String(g.grade || '').toUpperCase()) ? String(g.grade).toUpperCase() : null,
|
||||
})).filter((g) => g.player && g.grade),
|
||||
};
|
||||
}
|
||||
|
||||
function shapeVictoryPayload(b) {
|
||||
return {
|
||||
...shapeSinglePropPayload(b),
|
||||
result_actual: pickStr(b.result_actual || b.actual || '', 64) || 'HIT',
|
||||
};
|
||||
}
|
||||
|
||||
function shapePayload(type, body) {
|
||||
switch (type) {
|
||||
case 'grade': return shapeSinglePropPayload(body);
|
||||
case 'gotd': return shapeSinglePropPayload(body);
|
||||
case 'victory': return shapeVictoryPayload(body);
|
||||
case 'recap': return shapeRecapPayload(body);
|
||||
case 'cheatsheet': return shapeCheatsheetPayload(body);
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
function hashKey(type, format, payload) {
|
||||
const json = JSON.stringify({ type, format, payload });
|
||||
return crypto.createHash('sha256').update(json).digest('hex').slice(0, 24);
|
||||
}
|
||||
|
||||
// ── route ─────────────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const ip = (req.headers['x-forwarded-for'] || req.ip || 'unknown').toString().split(',')[0].trim();
|
||||
if (!checkRate(ip)) {
|
||||
return res.status(429).json({ error: 'rate limit exceeded — 30 cards/min' });
|
||||
}
|
||||
|
||||
const { type, format, errors } = validateBase(req.body || {});
|
||||
if (errors.length) return res.status(400).json({ error: 'invalid input', detail: errors });
|
||||
|
||||
const payload = shapePayload(type, req.body || {});
|
||||
const key = hashKey(type, format, payload);
|
||||
const cachePath = path.join(CACHE_DIR, `${key}.png`);
|
||||
|
||||
// Cache check
|
||||
try {
|
||||
const cached = await fs.readFile(cachePath);
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('X-Cache', 'HIT');
|
||||
res.set('Cache-Control', 'public, max-age=900');
|
||||
return res.send(cached);
|
||||
} catch { /* miss */ }
|
||||
|
||||
let svg;
|
||||
try {
|
||||
svg = renderer.buildSvg(type, format, payload);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: 'render failed', detail: err.message });
|
||||
}
|
||||
|
||||
// Optional SVG-only mode (no rasterization)
|
||||
if (req.query.svg === '1') {
|
||||
res.set('Content-Type', 'image/svg+xml');
|
||||
res.set('X-Cache', 'MISS');
|
||||
return res.send(svg);
|
||||
}
|
||||
|
||||
let png;
|
||||
try {
|
||||
png = await renderer.rasterize(svg);
|
||||
} catch (err) {
|
||||
if (err && err.code === 'SHARP_UNAVAILABLE') {
|
||||
// Degrade: hand back SVG so the channel-side renderer can still embed.
|
||||
res.set('Content-Type', 'image/svg+xml');
|
||||
res.set('X-Cache', 'MISS');
|
||||
res.set('X-Degraded', 'svg-fallback');
|
||||
return res.send(svg);
|
||||
}
|
||||
return res.status(500).json({ error: 'rasterize failed', detail: err.message });
|
||||
}
|
||||
|
||||
// Write cache (best-effort; ignore failures so the response still flies)
|
||||
fs.writeFile(cachePath, png).catch(() => {});
|
||||
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('X-Cache', 'MISS');
|
||||
res.set('Cache-Control', 'public, max-age=900');
|
||||
return res.send(png);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,111 @@
|
||||
const express = require('express');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Kill bad satisfieds before they satisfieds you' };
|
||||
|
||||
// GET /parlays-graded — total scan count
|
||||
router.get('/parlays-graded', async (req, res) => {
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { count, error } = await supabase
|
||||
.from('scan_sessions')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
res.set(MISSION_HEADER).json({ count: count || 0 });
|
||||
} catch (err) {
|
||||
console.error('[stats/parlays-graded]', err.message);
|
||||
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /public — public dashboard stats
|
||||
router.get('/public', async (req, res) => {
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
// Total parlays graded
|
||||
const { count: parlaysGraded, error: countErr } = await supabase
|
||||
.from('scan_sessions')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
if (countErr) throw countErr;
|
||||
|
||||
// Most common grade
|
||||
const { data: grades, error: gradesErr } = await supabase
|
||||
.from('scan_sessions')
|
||||
.select('final_grade');
|
||||
if (gradesErr) throw gradesErr;
|
||||
|
||||
let avg_grade = null;
|
||||
if (grades && grades.length > 0) {
|
||||
const freq = {};
|
||||
for (const row of grades) {
|
||||
const g = row.final_grade;
|
||||
if (g) freq[g] = (freq[g] || 0) + 1;
|
||||
}
|
||||
let maxCount = 0;
|
||||
for (const [grade, c] of Object.entries(freq)) {
|
||||
if (c > maxCount) {
|
||||
maxCount = c;
|
||||
avg_grade = grade;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kill conditions caught
|
||||
const { data: picks, error: picksErr } = await supabase
|
||||
.from('picks')
|
||||
.select('kill_conditions')
|
||||
.not('kill_conditions', 'eq', '[]');
|
||||
if (picksErr) throw picksErr;
|
||||
|
||||
const kill_conditions_caught = picks ? picks.filter(p =>
|
||||
p.kill_conditions && Array.isArray(p.kill_conditions) && p.kill_conditions.length > 0
|
||||
).length : 0;
|
||||
|
||||
res.set(MISSION_HEADER).json({
|
||||
parlays_graded: parlaysGraded || 0,
|
||||
avg_grade,
|
||||
kill_conditions_caught,
|
||||
sports_covered: ['NBA', 'MLB'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[stats/public]', err.message);
|
||||
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /live — top 3 most recently graded props
|
||||
router.get('/live', async (req, res) => {
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('picks')
|
||||
.select('player, stat_type, line, direction, grade, confidence, created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(3);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const result = (data || []).map(row => ({
|
||||
player: row.player,
|
||||
stat: row.stat_type,
|
||||
line: row.line,
|
||||
direction: row.direction,
|
||||
grade: row.grade,
|
||||
confidence: row.confidence,
|
||||
sport: 'NBA',
|
||||
graded_at: row.created_at,
|
||||
}));
|
||||
|
||||
res.set(MISSION_HEADER).json(result);
|
||||
} catch (err) {
|
||||
console.error('[stats/live]', err.message);
|
||||
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -22,7 +22,7 @@ router.post('/checkout', requireAuth, async (req, res) => {
|
||||
const result = await createCheckoutSession(req.user.id, req.user.email, tier, founder_code);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Checkout error:', err.message);
|
||||
console.error('[VYNDR] Checkout error:', err.message);
|
||||
return res.status(503).json({ error: 'Checkout creation failed' });
|
||||
}
|
||||
});
|
||||
@@ -40,7 +40,7 @@ router.post('/webhook', async (req, res) => {
|
||||
try {
|
||||
event = constructWebhookEvent(req.body, signature);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Webhook signature failed:', err.message);
|
||||
console.error('[VYNDR] Webhook signature failed:', err.message);
|
||||
return res.status(400).json({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ router.post('/webhook', async (req, res) => {
|
||||
await handleWebhookEvent(event);
|
||||
return res.json({ received: true });
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Webhook handler error:', err.message);
|
||||
console.error('[VYNDR] Webhook handler error:', err.message);
|
||||
return res.status(500).json({ error: 'Webhook processing failed' });
|
||||
}
|
||||
});
|
||||
@@ -63,7 +63,7 @@ router.post('/portal', requireAuth, async (req, res) => {
|
||||
const result = await createPortalSession(req.user.stripe_customer_id);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Portal error:', err.message);
|
||||
console.error('[VYNDR] Portal error:', err.message);
|
||||
return res.status(503).json({ error: 'Portal creation failed' });
|
||||
}
|
||||
});
|
||||
@@ -82,7 +82,7 @@ router.get('/status', requireAuth, async (req, res) => {
|
||||
...subStatus,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Status error:', err.message);
|
||||
console.error('[VYNDR] Status error:', err.message);
|
||||
return res.status(503).json({ error: 'Status check failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
const express = require('express');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const { email, list, website } = req.body;
|
||||
|
||||
// Honeypot check — bots fill hidden "website" field, humans don't
|
||||
if (website) {
|
||||
// Silently discard — return 200 so bots think it worked
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
if (!email || !email.includes('@') || !list) {
|
||||
return res.status(400).json({ error: 'Email and list name required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
await supabase.from('waitlist').upsert(
|
||||
{ email: email.toLowerCase().trim(), list_name: list },
|
||||
{ onConflict: 'email,list_name' }
|
||||
);
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[VYNDR] Waitlist error:', err.message);
|
||||
return res.json({ success: true }); // Never reveal errors to potential bots
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* GET /api/widget — public embeddable widget data feed.
|
||||
*
|
||||
* - CORS: open to all origins (it's a public widget).
|
||||
* - Cache: 15 minutes server-side + Cache-Control headers.
|
||||
* - Rate limit: 60 req/min/Origin (60/min/IP if no Origin header).
|
||||
*
|
||||
* Response: tonight's top 3 grades, sport-filterable.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost:4000';
|
||||
const CACHE_TTL_MS = 15 * 60_000;
|
||||
const RATE_WINDOW_MS = 60_000;
|
||||
const RATE_MAX = 60;
|
||||
|
||||
// Per-key sliding-window counter
|
||||
const buckets = new Map();
|
||||
function checkRate(key) {
|
||||
const now = Date.now();
|
||||
const arr = (buckets.get(key) || []).filter((t) => now - t < RATE_WINDOW_MS);
|
||||
if (arr.length >= RATE_MAX) return false;
|
||||
arr.push(now);
|
||||
buckets.set(key, arr);
|
||||
return true;
|
||||
}
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, arr] of buckets.entries()) {
|
||||
const fresh = arr.filter((t) => now - t < RATE_WINDOW_MS);
|
||||
if (fresh.length === 0) buckets.delete(k);
|
||||
else buckets.set(k, fresh);
|
||||
}
|
||||
}, RATE_WINDOW_MS).unref?.();
|
||||
|
||||
// Tiny in-memory cache keyed by sport
|
||||
const cache = new Map();
|
||||
function cacheGet(key) {
|
||||
const hit = cache.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() - hit.at > CACHE_TTL_MS) { cache.delete(key); return null; }
|
||||
return hit.value;
|
||||
}
|
||||
function cacheSet(key, value) {
|
||||
cache.set(key, { at: Date.now(), value });
|
||||
}
|
||||
|
||||
const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb']);
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
// CORS — open to all origins for the widget feed only. We DO NOT echo
|
||||
// request headers; we send an explicit allow-list of headers we accept.
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
res.set('Access-Control-Allow-Methods', 'GET');
|
||||
res.set('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.set('Vary', 'Origin');
|
||||
|
||||
const origin = req.get('Origin') || (req.headers['x-forwarded-for'] || req.ip || 'unknown').toString();
|
||||
if (!checkRate(origin)) return res.status(429).json({ error: 'rate limit exceeded' });
|
||||
|
||||
const sport = String(req.query.sport || 'nba').toLowerCase();
|
||||
if (!VALID_SPORTS.has(sport)) return res.status(400).json({ error: 'invalid sport' });
|
||||
|
||||
const cached = cacheGet(sport);
|
||||
if (cached) {
|
||||
res.set('X-Cache', 'HIT');
|
||||
res.set('Cache-Control', 'public, max-age=900');
|
||||
return res.json(cached);
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await axios.get(`${API_BASE}/api/props/top-graded?limit=3&sport=${encodeURIComponent(sport)}`, { timeout: 8_000 });
|
||||
const props = Array.isArray(r.data?.props) ? r.data.props.slice(0, 3) : [];
|
||||
const payload = {
|
||||
sport,
|
||||
generated_at: new Date().toISOString(),
|
||||
props: props.map((p) => ({
|
||||
player: p.player_name || p.player,
|
||||
sport: p.sport,
|
||||
stat: p.stat_type || p.stat,
|
||||
direction: p.direction,
|
||||
line: p.line,
|
||||
grade: p.grade,
|
||||
})),
|
||||
link: 'https://vyndr.app',
|
||||
};
|
||||
cacheSet(sport, payload);
|
||||
res.set('X-Cache', 'MISS');
|
||||
res.set('Cache-Control', 'public, max-age=900');
|
||||
return res.json(payload);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: 'upstream unavailable', detail: err?.message || 'unknown' });
|
||||
}
|
||||
});
|
||||
|
||||
router.options('/', (_req, res) => {
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
res.set('Access-Control-Allow-Methods', 'GET');
|
||||
res.set('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.set('Access-Control-Max-Age', '86400');
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+5
-2
@@ -1,7 +1,10 @@
|
||||
const app = require('./app');
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
// Default 3001 — Next.js owns 3000 locally and in production. The poller,
|
||||
// internal cron, and BASE_URL conventions all assume 3001 for the Express
|
||||
// backend. PORT env still overrides for special-case deploys.
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[BetonBLK] Server running on port ${PORT}`);
|
||||
console.log(`[VYNDR] Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* UnifiedOddsProvider — orchestrator.
|
||||
*
|
||||
* Fan-out to every adapter via Promise.allSettled, normalize, attach
|
||||
* cross-source signals, run processing engines, return a structured
|
||||
* `sources` array describing what contributed.
|
||||
*
|
||||
* Never throws. Adapter failures are surfaced per-source so the API caller
|
||||
* can see exactly which upstreams contributed to a given refresh.
|
||||
*/
|
||||
|
||||
const espn = require('./adapters/ESPNAdapter');
|
||||
const pinnacle = require('./adapters/PinnacleAdapter');
|
||||
const draftkings = require('./adapters/DraftKingsAdapter');
|
||||
const fanduel = require('./adapters/FanDuelAdapter');
|
||||
const betmgm = require('./adapters/BetMGMAdapter');
|
||||
const caesars = require('./adapters/CaesarsAdapter');
|
||||
const prizepicks = require('./adapters/PrizePicksAdapter');
|
||||
const covers = require('./adapters/CoversAdapter');
|
||||
const rotowire = require('./adapters/RotowireAdapter');
|
||||
|
||||
const lineShopping = require('./processing/LineShoppingEngine');
|
||||
const middles = require('./processing/MiddlesDetector');
|
||||
const ev = require('./processing/EVCalculator');
|
||||
|
||||
const { shouldCollect, isActiveSport } = require('../config/sports');
|
||||
const rateLimiter = require('./rateLimiter');
|
||||
const breaker = require('./circuitBreaker');
|
||||
|
||||
const ADAPTERS = [espn, pinnacle, draftkings, fanduel, betmgm, caesars, prizepicks, covers, rotowire];
|
||||
|
||||
function settleToResult(name, settled) {
|
||||
if (settled.status === 'fulfilled') {
|
||||
const value = settled.value;
|
||||
const count = Array.isArray(value) ? value.length : (value?.projections?.length ?? 0);
|
||||
return { source: name, ok: true, count };
|
||||
}
|
||||
const err = settled.reason;
|
||||
return {
|
||||
source: name,
|
||||
ok: false,
|
||||
error: err?.code === 'NOT_IMPLEMENTED' ? 'not_implemented'
|
||||
: err?.code === 'BREAKER_OPEN' ? 'breaker_open'
|
||||
: err?.code === 'RATE_LIMIT_TIMEOUT' ? 'rate_limited'
|
||||
: (err?.message || 'unknown'),
|
||||
};
|
||||
}
|
||||
|
||||
async function fullRefresh(sport, { gradedProps = [] } = {}) {
|
||||
if (!shouldCollect(sport)) {
|
||||
return {
|
||||
sport,
|
||||
collected: false,
|
||||
reason: 'sport not in collection set',
|
||||
sources: [],
|
||||
data: { games: [], props: [], shopped: [], middles: [] },
|
||||
refreshed_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
espn.getGames(sport),
|
||||
pinnacle.getGames(sport),
|
||||
draftkings.getPlayerProps(sport),
|
||||
fanduel.getPlayerProps(sport),
|
||||
betmgm.getPlayerProps(sport),
|
||||
caesars.getPlayerProps(sport),
|
||||
prizepicks.getPlayerProps(sport),
|
||||
covers.getConsensus?.(sport) ?? Promise.resolve([]),
|
||||
rotowire.getProjections(sport),
|
||||
]);
|
||||
|
||||
const sources = [
|
||||
settleToResult('espn', results[0]),
|
||||
settleToResult('pinnacle', results[1]),
|
||||
settleToResult('draftkings', results[2]),
|
||||
settleToResult('fanduel', results[3]),
|
||||
settleToResult('betmgm', results[4]),
|
||||
settleToResult('caesars', results[5]),
|
||||
settleToResult('prizepicks', results[6]),
|
||||
settleToResult('covers', results[7]),
|
||||
settleToResult('rotowire', results[8]),
|
||||
];
|
||||
|
||||
const games = results[0].status === 'fulfilled' ? results[0].value : [];
|
||||
|
||||
// Merge every adapter's player-prop payload (those that returned arrays).
|
||||
const props = [];
|
||||
for (const idx of [2, 3, 4, 5, 6]) {
|
||||
if (results[idx].status === 'fulfilled' && Array.isArray(results[idx].value)) {
|
||||
props.push(...results[idx].value);
|
||||
}
|
||||
}
|
||||
|
||||
const shopped = lineShopping.process(props);
|
||||
const middlesFound = middles.detect(shopped);
|
||||
|
||||
// EV labels for any already-graded props the caller fed in.
|
||||
const evRows = (gradedProps || []).map((g) => ({
|
||||
key: g.key,
|
||||
grade: g.grade,
|
||||
odds: g.odds,
|
||||
ev: ev.calculate({ grade: g.grade, odds: g.odds }),
|
||||
}));
|
||||
|
||||
return {
|
||||
sport,
|
||||
active: isActiveSport(sport),
|
||||
sources,
|
||||
data: {
|
||||
games,
|
||||
props,
|
||||
shopped,
|
||||
middles: middlesFound,
|
||||
ev: evRows,
|
||||
},
|
||||
refreshed_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function status() {
|
||||
return {
|
||||
rate_limiters: rateLimiter.snapshot(),
|
||||
breakers: breaker.snapshot(),
|
||||
adapters: ADAPTERS.map((a) => a.name),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { fullRefresh, status };
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* BetMGM adapter — STUB.
|
||||
*
|
||||
* BetMGM props live under `sports.betmgm.com` JSON endpoints. State-gated
|
||||
* by IP; requires a state code in the path.
|
||||
*/
|
||||
|
||||
const SOURCE = 'betmgm';
|
||||
|
||||
async function getGames(/* sport */) { return []; }
|
||||
async function getPlayerProps(/* sport */) { return []; }
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps };
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Caesars adapter — STUB.
|
||||
*
|
||||
* Caesars exposes props via `sportsbook.caesars.com` GraphQL endpoints. Same
|
||||
* geo-gating story as DraftKings/FanDuel.
|
||||
*/
|
||||
|
||||
const SOURCE = 'caesars';
|
||||
|
||||
async function getGames(/* sport */) { return []; }
|
||||
async function getPlayerProps(/* sport */) { return []; }
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps };
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Covers consensus adapter — STUB.
|
||||
*
|
||||
* Covers.com publishes public-betting consensus percentages by game. Used
|
||||
* by the CONTRARIAN / CONFIRMED badge logic. No official API — page scrape.
|
||||
*/
|
||||
|
||||
const SOURCE = 'covers';
|
||||
|
||||
async function getConsensus(/* sport */) { return []; }
|
||||
async function getGames(/* sport */) { return []; }
|
||||
async function getPlayerProps(/* sport */) { return []; }
|
||||
|
||||
module.exports = { name: SOURCE, getConsensus, getGames, getPlayerProps };
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* DraftKings adapter — STUB.
|
||||
*
|
||||
* DK serves props through an undocumented REST API behind
|
||||
* sportsbook-nash.draftkings.com / api.draftkings.com. Endpoints are stable
|
||||
* for weeks but break without warning. Categories and subcategory IDs vary
|
||||
* per sport and per market.
|
||||
*
|
||||
* Implementation TODOs are tracked in specs/data-pipeline-books.md.
|
||||
* Until that's done this adapter conforms to the contract and returns []
|
||||
* so the orchestrator records "draftkings: 0" rather than failing.
|
||||
*/
|
||||
|
||||
const { NotImplementedAdapter } = require('./OddsAdapter');
|
||||
|
||||
const SOURCE = 'draftkings';
|
||||
|
||||
async function getGames(/* sport */) {
|
||||
return [];
|
||||
}
|
||||
|
||||
async function getPlayerProps(/* sport */) {
|
||||
// STUB: returning [] keeps the unified provider's `sources` array honest.
|
||||
// When real impl lands, this should use the rate limiter + breaker.
|
||||
return [];
|
||||
}
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps, NotImplementedAdapter };
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* ESPN public scoreboard adapter.
|
||||
*
|
||||
* ESPN's `site.api.espn.com` scoreboard endpoints are unauthenticated and
|
||||
* stable. They cover every sport we care about. We use them for:
|
||||
* - Game schedule + status (scheduled / in progress / final)
|
||||
* - Game-level moneyline + spread + total (when ESPN has odds)
|
||||
* - Live scores during in-progress games
|
||||
*
|
||||
* They do NOT carry player props — that's other adapters' job.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const rateLimiter = require('../rateLimiter');
|
||||
const breaker = require('../circuitBreaker');
|
||||
|
||||
const ENDPOINTS = Object.freeze({
|
||||
nba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard',
|
||||
wnba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard',
|
||||
mlb: 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard',
|
||||
nfl: 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard',
|
||||
nhl: 'https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/scoreboard',
|
||||
mma: 'https://site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard',
|
||||
golf: 'https://site.api.espn.com/apis/site/v2/sports/golf/pga/scoreboard',
|
||||
boxing: 'https://site.api.espn.com/apis/site/v2/sports/boxing/scoreboard',
|
||||
});
|
||||
|
||||
const HTTP_TIMEOUT_MS = 8_000;
|
||||
const SOURCE = 'espn';
|
||||
|
||||
function normalizeGame(event, sport) {
|
||||
const competition = event.competitions?.[0];
|
||||
if (!competition) return null;
|
||||
const competitors = competition.competitors || [];
|
||||
const home = competitors.find((c) => c.homeAway === 'home');
|
||||
const away = competitors.find((c) => c.homeAway === 'away');
|
||||
const oddsRow = (competition.odds || [])[0];
|
||||
|
||||
return {
|
||||
game_id: String(event.id),
|
||||
sport,
|
||||
away: away?.team?.abbreviation || null,
|
||||
home: home?.team?.abbreviation || null,
|
||||
away_name: away?.team?.displayName || null,
|
||||
home_name: home?.team?.displayName || null,
|
||||
start_time: event.date,
|
||||
status: event.status?.type?.name || null, // STATUS_SCHEDULED | STATUS_IN_PROGRESS | STATUS_FINAL
|
||||
venue: competition.venue?.fullName || null,
|
||||
odds: oddsRow
|
||||
? {
|
||||
provider: oddsRow.provider?.name || null,
|
||||
details: oddsRow.details || null,
|
||||
spread: oddsRow.spread ?? null,
|
||||
over_under: oddsRow.overUnder ?? null,
|
||||
}
|
||||
: null,
|
||||
score:
|
||||
event.status?.type?.state === 'in'
|
||||
? {
|
||||
away: parseInt(away?.score ?? '0', 10),
|
||||
home: parseInt(home?.score ?? '0', 10),
|
||||
period: event.status?.period,
|
||||
clock: event.status?.displayClock,
|
||||
}
|
||||
: null,
|
||||
source: SOURCE,
|
||||
fetched_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function getGames(sport) {
|
||||
const url = ENDPOINTS[sport];
|
||||
if (!url) {
|
||||
const err = new Error(`espn adapter does not support sport: ${sport}`);
|
||||
err.skipBreaker = true;
|
||||
throw err;
|
||||
}
|
||||
await rateLimiter.take(SOURCE);
|
||||
return breaker.call(SOURCE, async () => {
|
||||
const res = await axios.get(url, {
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
headers: { 'User-Agent': 'VYNDR/1.0 (+https://vyndr.app)' },
|
||||
validateStatus: (s) => s >= 200 && s < 500,
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
const err = new Error(`espn returned ${res.status}`);
|
||||
err.upstream = SOURCE;
|
||||
throw err;
|
||||
}
|
||||
const events = Array.isArray(res.data?.events) ? res.data.events : [];
|
||||
return events.map((e) => normalizeGame(e, sport)).filter(Boolean);
|
||||
});
|
||||
}
|
||||
|
||||
async function getPlayerProps(/* sport */) {
|
||||
// ESPN's public API doesn't carry player props.
|
||||
return [];
|
||||
}
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps, ENDPOINTS };
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* FanDuel adapter — STUB.
|
||||
*
|
||||
* FanDuel serves props through `sbapi.fanduel.com` and `app.fanduel.com`
|
||||
* endpoints. Geo-restricted; requires a state-specific session for live
|
||||
* data. Same caveats as DraftKings.
|
||||
*/
|
||||
|
||||
const SOURCE = 'fanduel';
|
||||
|
||||
async function getGames(/* sport */) { return []; }
|
||||
async function getPlayerProps(/* sport */) { return []; }
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps };
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* OddsAdapter — common interface every odds source must implement.
|
||||
*
|
||||
* The UnifiedOddsProvider calls these methods on every adapter via
|
||||
* Promise.allSettled, so adapters should never throw at the module boundary
|
||||
* — surface failures as a fulfilled result with `error` set, or a rejected
|
||||
* promise that the orchestrator can attribute to a specific source.
|
||||
*
|
||||
* Return shapes:
|
||||
* getGames(sport): Game[]
|
||||
* getPlayerProps(sport): PlayerProp[]
|
||||
*
|
||||
* Game = {
|
||||
* game_id, sport, away, home, start_time, status, score?,
|
||||
* moneyline?: { away, home }, spread?: { line, juice }, total?: { line, juice }
|
||||
* }
|
||||
*
|
||||
* PlayerProp = {
|
||||
* game_id, player_name, player_id?, stat_type, line,
|
||||
* odds_over, odds_under, book, fetched_at
|
||||
* }
|
||||
*/
|
||||
|
||||
class NotImplementedAdapter {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
}
|
||||
async getGames(/* sport */) {
|
||||
return this._notImplemented('getGames');
|
||||
}
|
||||
async getPlayerProps(/* sport */) {
|
||||
return this._notImplemented('getPlayerProps');
|
||||
}
|
||||
_notImplemented(method) {
|
||||
const err = new Error(`${this.name}.${method} not implemented`);
|
||||
err.code = 'NOT_IMPLEMENTED';
|
||||
err.skipBreaker = true; // don't penalize the breaker for missing impls
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { NotImplementedAdapter };
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Pinnacle sharp-reference adapter.
|
||||
*
|
||||
* Pinnacle's lines are the sharp benchmark every other book chases. We don't
|
||||
* scrape Pinnacle's site directly (TOS-grey, anti-bot). Instead we read from
|
||||
* a configurable upstream — by default the public-facing `pinnacle.com` REST
|
||||
* API used by their own site. If `PINNACLE_API_BASE` is set in env we use
|
||||
* that (typical: a paid odds provider that proxies Pinnacle).
|
||||
*
|
||||
* The adapter implements the OddsAdapter contract — failure mode is an empty
|
||||
* array, not a thrown error, so the orchestrator's `sources` array reflects
|
||||
* who actually contributed.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const rateLimiter = require('../rateLimiter');
|
||||
const breaker = require('../circuitBreaker');
|
||||
|
||||
const SOURCE = 'pinnacle';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
const SPORT_IDS = Object.freeze({
|
||||
// These are Pinnacle's internal sport IDs as observed on pinnacle.com's
|
||||
// public guest API. They occasionally change.
|
||||
nba: 4,
|
||||
wnba: 4,
|
||||
mlb: 9,
|
||||
nhl: 17,
|
||||
nfl: 29,
|
||||
mma: 22,
|
||||
golf: 12,
|
||||
});
|
||||
|
||||
const BASE = process.env.PINNACLE_API_BASE || 'https://guest.api.arcadia.pinnacle.com/0.1';
|
||||
|
||||
function buildHeaders() {
|
||||
// Pinnacle's guest API expects an X-API-Key header on calls; the public
|
||||
// site embeds it in JS. If you have one, set PINNACLE_API_KEY.
|
||||
const key = process.env.PINNACLE_API_KEY;
|
||||
const headers = {
|
||||
'User-Agent': 'VYNDR/1.0',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
if (key) headers['X-API-Key'] = key;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function getGames(sport) {
|
||||
const sportId = SPORT_IDS[sport];
|
||||
if (!sportId) {
|
||||
const err = new Error(`pinnacle adapter does not support sport: ${sport}`);
|
||||
err.skipBreaker = true;
|
||||
throw err;
|
||||
}
|
||||
if (!process.env.PINNACLE_API_KEY) {
|
||||
// Without an API key Pinnacle's guest endpoint refuses requests. Return
|
||||
// empty so the orchestrator surfaces a clean 'pinnacle: 0 games' status
|
||||
// rather than tripping the breaker.
|
||||
return [];
|
||||
}
|
||||
|
||||
await rateLimiter.take(SOURCE);
|
||||
return breaker.call(SOURCE, async () => {
|
||||
const url = `${BASE}/sports/${sportId}/matchups`;
|
||||
const res = await axios.get(url, {
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
headers: buildHeaders(),
|
||||
validateStatus: (s) => s >= 200 && s < 500,
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
const err = new Error(`pinnacle returned ${res.status}`);
|
||||
err.upstream = SOURCE;
|
||||
throw err;
|
||||
}
|
||||
const matchups = Array.isArray(res.data) ? res.data : [];
|
||||
return matchups.map((m) => ({
|
||||
game_id: String(m.id ?? m.matchupId ?? ''),
|
||||
sport,
|
||||
home: m.participants?.find?.((p) => p.alignment === 'home')?.name || null,
|
||||
away: m.participants?.find?.((p) => p.alignment === 'away')?.name || null,
|
||||
start_time: m.startTime || null,
|
||||
status: m.status || null,
|
||||
source: SOURCE,
|
||||
fetched_at: new Date().toISOString(),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function getPlayerProps(/* sport */) {
|
||||
// Pinnacle does carry player props but they live behind a separate prices
|
||||
// endpoint and are only emitted close to game time. Wired here as TODO so
|
||||
// the orchestrator just gets an empty array until we light it up.
|
||||
return [];
|
||||
}
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps, SPORT_IDS };
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* PrizePicks adapter — STUB.
|
||||
*
|
||||
* PrizePicks projections come from `api.prizepicks.com` (public). They have
|
||||
* one of the cleanest schemas of any DFS provider; if we light this up early
|
||||
* it gives us an excellent multi-source comparison signal.
|
||||
*
|
||||
* TODO: implement against /projections + /players + /leagues.
|
||||
*/
|
||||
|
||||
const SOURCE = 'prizepicks';
|
||||
|
||||
async function getGames(/* sport */) { return []; }
|
||||
async function getPlayerProps(/* sport */) { return []; }
|
||||
|
||||
module.exports = { name: SOURCE, getGames, getPlayerProps };
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Rotowire daily projections adapter — STUB.
|
||||
*
|
||||
* Rotowire publishes daily lineup + projection pages per sport. Layout is
|
||||
* HTML-driven and changes seasonally; we'll lock in selectors against a
|
||||
* snapshot before flipping this on.
|
||||
*
|
||||
* Why it matters: when Rotowire's number agrees with VYNDR's projection
|
||||
* AND disagrees with the book line, model-stack confidence increases.
|
||||
*/
|
||||
|
||||
const SOURCE = 'rotowire';
|
||||
|
||||
async function getProjections(/* sport */) { return { sport: null, projections: [], note: 'not implemented' }; }
|
||||
|
||||
module.exports = { name: SOURCE, getProjections };
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* College Football Data (CFBD) — advanced college analytics.
|
||||
*
|
||||
* 100% free API key. Covers historical betting lines, player usage, team
|
||||
* talent composites, advanced efficiency (PPA), recruiting. nba_api
|
||||
* doesn't cover college; CFBD fills the gap for NCAAB and NCAAFB props.
|
||||
*
|
||||
* Note: CFBD's primary product is college football. College *basketball*
|
||||
* coverage is via the /cbb endpoints. We expose both via the sport-aware
|
||||
* functions below.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
|
||||
const SOURCE = 'cfbd';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_SECONDS = 6 * 60 * 60; // 6h — most CFBD data is daily-fresh
|
||||
|
||||
const BASE_URL = process.env.CFBD_BASE_URL || 'https://api.collegefootballdata.com';
|
||||
|
||||
// Generous free tier — 10 req/min keeps us well under documented limits.
|
||||
const limiter = createLimiter({ tokensPerInterval: 10, interval: 60_000 });
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
function configured() {
|
||||
return !!process.env.CFBD_KEY;
|
||||
}
|
||||
|
||||
async function fetchWithGuards(url, params, cacheKey) {
|
||||
if (!configured()) return null;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
await limiter.waitForToken();
|
||||
try {
|
||||
const data = await breaker.call(async () => {
|
||||
const res = await axios.get(url, {
|
||||
params,
|
||||
headers: { Authorization: `Bearer ${process.env.CFBD_KEY}` },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const err = new Error('cfbd rate limited');
|
||||
err.code = 'CFBD_429';
|
||||
throw err;
|
||||
}
|
||||
return res.data;
|
||||
});
|
||||
await cacheSet(cacheKey, data, CACHE_TTL_SECONDS);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err?.code === 'CIRCUIT_OPEN') return null;
|
||||
console.warn(`[${SOURCE}] fetch failed for ${cacheKey}:`, err?.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTeamStats(team, year) {
|
||||
const cacheKey = `cfbd:teamstats:${team}:${year}`;
|
||||
const data = await fetchWithGuards(`${BASE_URL}/stats/season`, { year, team }, cacheKey);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async function getPlayerUsage(player, team, year) {
|
||||
const cacheKey = `cfbd:usage:${player}:${team}:${year}`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/player/usage`,
|
||||
{ year, team, player },
|
||||
cacheKey
|
||||
);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async function getTalentComposite(team, year) {
|
||||
const cacheKey = `cfbd:talent:${team}:${year}`;
|
||||
const data = await fetchWithGuards(`${BASE_URL}/talent`, { year }, cacheKey);
|
||||
if (!Array.isArray(data)) return null;
|
||||
return data.find((row) => (row.school || row.team) === team) || null;
|
||||
}
|
||||
|
||||
async function getHistoricalLines(team, year) {
|
||||
const cacheKey = `cfbd:lines:${team}:${year}`;
|
||||
const data = await fetchWithGuards(`${BASE_URL}/lines`, { year, team }, cacheKey);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
getTeamStats,
|
||||
getPlayerUsage,
|
||||
getTalentComposite,
|
||||
getHistoricalLines,
|
||||
__internals: { limiter, breaker, BASE_URL },
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* OddsPapi — Pinnacle closing-line capture for CLV.
|
||||
*
|
||||
* Closing lines are immutable facts. Once captured at tip-off they live in
|
||||
* Supabase forever; we never re-read them, never cache them in Redis (would
|
||||
* be wasted space — they don't change).
|
||||
*
|
||||
* Called from the resolution poller the FIRST time it sees a game flip to
|
||||
* STATUS_IN_PROGRESS. One row per (game_id, player_espn_id, stat_type) via
|
||||
* the UNIQUE constraint in migration 016, so repeated triggers no-op
|
||||
* cleanly.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../utils/rateLimiter');
|
||||
const { devig } = require('../../utils/odds');
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const BASE_URL = process.env.ODDSPAPI_BASE_URL || 'https://api.oddspapi.io/v1';
|
||||
|
||||
const SPORT_KEYS = Object.freeze({
|
||||
nba: 'basketball_nba',
|
||||
wnba: 'basketball_wnba',
|
||||
mlb: 'baseball_mlb',
|
||||
nfl: 'americanfootball_nfl',
|
||||
nhl: 'icehockey_nhl',
|
||||
ncaab: 'basketball_ncaab',
|
||||
ncaafb: 'americanfootball_ncaaf',
|
||||
});
|
||||
|
||||
const limiter = createLimiter(API_BUDGETS.oddsPapi);
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
function configured() {
|
||||
return !!process.env.ODDSPAPI_KEY;
|
||||
}
|
||||
|
||||
function sportKey(sport) {
|
||||
const key = SPORT_KEYS[sport];
|
||||
if (!key) throw new Error(`Unsupported sport: ${sport}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function fetchPinnacleProp(sport, gameId, playerName, statType) {
|
||||
if (!configured()) return null;
|
||||
await limiter.waitForToken();
|
||||
try {
|
||||
return await breaker.call(async () => {
|
||||
const res = await axios.get(`${BASE_URL}/sports/${sportKey(sport)}/events/${gameId}/odds`, {
|
||||
params: { bookmaker: 'pinnacle', market: 'player_props' },
|
||||
headers: { 'X-Api-Key': process.env.ODDSPAPI_KEY },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
const props = res.data?.props || res.data?.data || [];
|
||||
return Array.isArray(props)
|
||||
? props.find(
|
||||
(p) =>
|
||||
(p.player ?? p.player_name)?.toLowerCase() === playerName.toLowerCase()
|
||||
&& (p.stat_type ?? p.market) === statType
|
||||
) || null
|
||||
: null;
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.code !== 'CIRCUIT_OPEN') {
|
||||
console.warn(`[oddspapi] fetch failed for ${sport}/${gameId}/${playerName}/${statType}:`, err?.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getPinnacleClosingLine(sport, gameId, playerEspnId, statType, playerName) {
|
||||
if (!configured()) return null;
|
||||
const prop = await fetchPinnacleProp(sport, gameId, playerName, statType);
|
||||
if (!prop) return null;
|
||||
const line = Number(prop.line ?? prop.point);
|
||||
const overOdds = Number(prop.over_price ?? prop.overOdds);
|
||||
const underOdds = Number(prop.under_price ?? prop.underOdds);
|
||||
if (!Number.isFinite(line) || !Number.isFinite(overOdds) || !Number.isFinite(underOdds)) return null;
|
||||
const fair = devig(overOdds, underOdds);
|
||||
return {
|
||||
line,
|
||||
overOdds,
|
||||
underOdds,
|
||||
fairOver: fair?.fairOver ?? null,
|
||||
fairUnder: fair?.fairUnder ?? null,
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function batchCapture(sport, gameId) {
|
||||
if (!configured()) return { captured: 0, skipped: 0, reason: 'not_configured' };
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
// Pull every unresolved prop for this game from the grading pipeline.
|
||||
// resolved_at IS NULL prevents double-capture for games we've already
|
||||
// processed (matters for retries from the poller).
|
||||
const { data: graded, error } = await supabase
|
||||
.from('grade_history')
|
||||
.select('player_id, player_name, stat_type')
|
||||
.eq('game_id', gameId)
|
||||
.is('resolved_at', null);
|
||||
|
||||
if (error) {
|
||||
console.warn('[oddspapi] grade_history lookup failed:', error.message);
|
||||
return { captured: 0, error: error.message };
|
||||
}
|
||||
if (!graded || graded.length === 0) {
|
||||
return { captured: 0, skipped: 0, reason: 'no_graded_props' };
|
||||
}
|
||||
|
||||
// Deduplicate by (player, stat) — same player can be graded twice on
|
||||
// different lines but we only need one Pinnacle reference per stat.
|
||||
const seen = new Set();
|
||||
const targets = [];
|
||||
for (const row of graded) {
|
||||
const key = `${row.player_id}|${row.stat_type}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
targets.push(row);
|
||||
}
|
||||
|
||||
let captured = 0;
|
||||
let skipped = 0;
|
||||
for (const t of targets) {
|
||||
const line = await getPinnacleClosingLine(sport, gameId, t.player_id, t.stat_type, t.player_name);
|
||||
if (!line) { skipped += 1; continue; }
|
||||
const { error: upsertErr } = await supabase
|
||||
.from('closing_lines')
|
||||
.upsert({
|
||||
game_id: gameId,
|
||||
sport,
|
||||
player_name: t.player_name,
|
||||
player_espn_id: t.player_id,
|
||||
stat_type: t.stat_type,
|
||||
pinnacle_line: line.line,
|
||||
pinnacle_over_odds: line.overOdds,
|
||||
pinnacle_under_odds: line.underOdds,
|
||||
fair_over_probability: line.fairOver,
|
||||
fair_under_probability: line.fairUnder,
|
||||
}, { onConflict: 'game_id,player_espn_id,stat_type' });
|
||||
if (upsertErr) {
|
||||
console.warn('[oddspapi] closing_lines upsert failed:', upsertErr.message);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
captured += 1;
|
||||
}
|
||||
return { captured, skipped, total: targets.length };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
getPinnacleClosingLine,
|
||||
batchCapture,
|
||||
__internals: { limiter, breaker, SPORT_KEYS },
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* OpenRouter — LLM inference adapter (Engine 2).
|
||||
*
|
||||
* Primary: DeepSeek V3 (deepseek/deepseek-chat) — best reasoning/dollar,
|
||||
* returns clean JSON when asked nicely.
|
||||
* Fallback: Nemotron (nvidia/llama-3.3-nemotron-super-49b-v1) — used when
|
||||
* primary 429s, 5xxs, or times out.
|
||||
*
|
||||
* SECURITY POSTURE:
|
||||
* - OPENROUTER_API_KEY is the most sensitive secret in this app. We
|
||||
* accept the key from env and pass it as a Bearer header — it never
|
||||
* appears in URLs, logs, or error messages we emit. Axios errors that
|
||||
* wrap the request are caught before re-throw to scrub headers.
|
||||
* - We do NOT include the string 'VYNDR' in prompts. OpenRouter is a
|
||||
* pass-through to third-party models and we don't want our brand
|
||||
* name in their training/QA pipelines.
|
||||
*
|
||||
* EXPORTS:
|
||||
* configured() → boolean
|
||||
* analyze(systemMessage, userPrompt) → { response, modelUsed, latencyMs }
|
||||
* or null on total failure
|
||||
* getUsage() → { requestsToday, requestsRemaining }
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||
|
||||
const SOURCE = 'openrouter';
|
||||
const BASE_URL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
|
||||
const HTTP_TIMEOUT_MS = 30_000;
|
||||
|
||||
const PRIMARY_MODEL = process.env.OPENROUTER_PRIMARY_MODEL || 'deepseek/deepseek-chat';
|
||||
const FALLBACK_MODEL = process.env.OPENROUTER_FALLBACK_MODEL || 'nvidia/llama-3.3-nemotron-super-49b-v1';
|
||||
|
||||
// 20 req/min, 1000/day. The day counter is in-memory; it resets on process
|
||||
// restart. That's good enough for free-tier accounting — we hit the cap
|
||||
// well before midnight in normal traffic patterns.
|
||||
const limiter = createLimiter({ tokensPerInterval: 20, interval: 60_000 });
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
const DAILY_CAP = 1000;
|
||||
const usage = { requestsToday: 0, dayBucket: new Date().toISOString().slice(0, 10) };
|
||||
|
||||
function noteUsage() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (today !== usage.dayBucket) {
|
||||
usage.dayBucket = today;
|
||||
usage.requestsToday = 0;
|
||||
}
|
||||
usage.requestsToday += 1;
|
||||
}
|
||||
|
||||
function configured() {
|
||||
return !!process.env.OPENROUTER_API_KEY;
|
||||
}
|
||||
|
||||
function getUsage() {
|
||||
return {
|
||||
requestsToday: usage.requestsToday,
|
||||
requestsRemaining: Math.max(0, DAILY_CAP - usage.requestsToday),
|
||||
};
|
||||
}
|
||||
|
||||
// Scrub axios errors before anything user-facing — the headers, request
|
||||
// body, and full URL may contain the key.
|
||||
function scrubError(err) {
|
||||
return {
|
||||
code: err?.code,
|
||||
status: err?.response?.status,
|
||||
message: err?.message || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
async function callModel(model, systemMessage, userPrompt) {
|
||||
const start = Date.now();
|
||||
const body = {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemMessage },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: 500,
|
||||
// response_format works on OpenAI-compatible endpoints; harmless if a
|
||||
// model ignores it. We still validate the response ourselves.
|
||||
response_format: { type: 'json_object' },
|
||||
};
|
||||
const res = await axios.post(`${BASE_URL}/chat/completions`, body, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
// OpenRouter recommends setting referer + title for usage tracking.
|
||||
// Neither contains 'VYNDR' branding — they're generic per their docs.
|
||||
'HTTP-Referer': process.env.OPENROUTER_REFERER || 'https://vyndr.app',
|
||||
'X-Title': process.env.OPENROUTER_TITLE || 'Sports Analytics',
|
||||
},
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 429 || (s >= 500 && s < 600),
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const err = new Error('openrouter rate limited');
|
||||
err.code = 'OPENROUTER_429';
|
||||
throw err;
|
||||
}
|
||||
if (res.status >= 500) {
|
||||
const err = new Error(`openrouter 5xx (${res.status})`);
|
||||
err.code = 'OPENROUTER_5XX';
|
||||
throw err;
|
||||
}
|
||||
const content = res.data?.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
const err = new Error('openrouter empty response');
|
||||
err.code = 'OPENROUTER_EMPTY';
|
||||
throw err;
|
||||
}
|
||||
return { response: content, modelUsed: model, latencyMs: Date.now() - start };
|
||||
}
|
||||
|
||||
async function analyze(systemMessage, userPrompt) {
|
||||
if (!configured()) return null;
|
||||
if (typeof systemMessage !== 'string' || typeof userPrompt !== 'string') return null;
|
||||
if (usage.requestsToday >= DAILY_CAP) {
|
||||
console.warn(`[${SOURCE}] daily cap reached (${DAILY_CAP})`);
|
||||
return null;
|
||||
}
|
||||
await limiter.waitForToken();
|
||||
|
||||
// Try primary; on failure, retry once with the fallback model.
|
||||
try {
|
||||
const result = await breaker.call(() => callModel(PRIMARY_MODEL, systemMessage, userPrompt));
|
||||
noteUsage();
|
||||
return result;
|
||||
} catch (primaryErr) {
|
||||
const scrubbed = scrubError(primaryErr);
|
||||
if (primaryErr?.code === 'CIRCUIT_OPEN') {
|
||||
// Don't burn the second model when the breaker says everything is down.
|
||||
return null;
|
||||
}
|
||||
console.warn(`[${SOURCE}] primary failed:`, scrubbed);
|
||||
try {
|
||||
// Fallback bypasses the breaker — different model, different upstream.
|
||||
const result = await callModel(FALLBACK_MODEL, systemMessage, userPrompt);
|
||||
noteUsage();
|
||||
return result;
|
||||
} catch (fallbackErr) {
|
||||
console.warn(`[${SOURCE}] fallback also failed:`, scrubError(fallbackErr));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
analyze,
|
||||
getUsage,
|
||||
__internals: { limiter, breaker, callModel, scrubError, PRIMARY_MODEL, FALLBACK_MODEL, usage },
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* ParlayAPI — historical prop archive.
|
||||
*
|
||||
* Free tier: 1,000 credits/month. 3.7M historical prop closing records,
|
||||
* 1.56M game-line archive. "Drop-in for the-odds-api, up to 6× cheaper."
|
||||
*
|
||||
* When called:
|
||||
* 1. Historical pull script (scripts/pull-parlayapi-history.js) — bulk
|
||||
* 2. Trap detection — query historical hit rates for a player/stat combo
|
||||
* 3. Feature enrichment — historical line accuracy
|
||||
*
|
||||
* NOT used during real-time grading (credit-limited).
|
||||
* Historical data lands in Supabase `historical_props` (migration 017).
|
||||
*
|
||||
* Failure modes mirror sharpApiAdapter:
|
||||
* - 429 → back off, no stale cache (historical data isn't time-sensitive)
|
||||
* - 5xx → circuit breaker (3 fails → open 60s)
|
||||
* - timeout → 10s, breaker counts it
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
|
||||
const SOURCE = 'parlayapi';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24h — historical data is immutable
|
||||
|
||||
const BASE_URL = process.env.PARLAYAPI_BASE_URL || 'https://api.parlayapi.io/v1';
|
||||
|
||||
// Conservative budget: 5 req/min lets us spread 1,000 credits/month across the
|
||||
// month (~33/day). Bulk script overrides with its own pacing.
|
||||
const limiter = createLimiter({ tokensPerInterval: 5, interval: 60_000 });
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
const SPORT_KEYS = Object.freeze({
|
||||
nba: 'basketball_nba',
|
||||
wnba: 'basketball_wnba',
|
||||
mlb: 'baseball_mlb',
|
||||
nfl: 'americanfootball_nfl',
|
||||
nhl: 'icehockey_nhl',
|
||||
ncaab: 'basketball_ncaab',
|
||||
ncaafb: 'americanfootball_ncaaf',
|
||||
});
|
||||
|
||||
function configured() {
|
||||
return !!process.env.PARLAYAPI_KEY;
|
||||
}
|
||||
|
||||
function sportKey(sport) {
|
||||
const key = SPORT_KEYS[sport];
|
||||
if (!key) throw new Error(`Unsupported sport: ${sport}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function fetchWithGuards(url, params, cacheKey) {
|
||||
if (!configured()) return null;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
await limiter.waitForToken();
|
||||
try {
|
||||
const data = await breaker.call(async () => {
|
||||
const res = await axios.get(url, {
|
||||
params,
|
||||
headers: { 'X-Api-Key': process.env.PARLAYAPI_KEY },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const err = new Error('parlayapi rate limited');
|
||||
err.code = 'PARLAYAPI_429';
|
||||
throw err;
|
||||
}
|
||||
return res.data;
|
||||
});
|
||||
await cacheSet(cacheKey, data, CACHE_TTL_SECONDS);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err?.code === 'CIRCUIT_OPEN') return null;
|
||||
console.warn(`[${SOURCE}] fetch failed for ${cacheKey}:`, err?.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHistoricalProp(raw, sport) {
|
||||
return {
|
||||
sport,
|
||||
game_date: raw.game_date ?? raw.date ?? null,
|
||||
player_name: raw.player ?? raw.player_name ?? null,
|
||||
stat_type: raw.stat_type ?? raw.market ?? null,
|
||||
line: Number(raw.line ?? raw.point ?? null),
|
||||
closing_line: Number(raw.closing_line ?? raw.close ?? null) || null,
|
||||
result: raw.result ?? raw.outcome ?? null,
|
||||
source: SOURCE,
|
||||
};
|
||||
}
|
||||
|
||||
async function getHistoricalProps(sport, playerName, statType, limit = 50) {
|
||||
const key = sportKey(sport);
|
||||
const cacheKey = `parlayapi:hist:${sport}:${playerName}:${statType}:${limit}`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/historical/player_props`,
|
||||
{ sport: key, player: playerName, stat_type: statType, limit },
|
||||
cacheKey
|
||||
);
|
||||
if (!data) return [];
|
||||
const raw = data.props || data.results || data.data || [];
|
||||
return Array.isArray(raw) ? raw.map((r) => normalizeHistoricalProp(r, sport)) : [];
|
||||
}
|
||||
|
||||
async function getClosingLines(sport, gameDate) {
|
||||
const key = sportKey(sport);
|
||||
const cacheKey = `parlayapi:close:${sport}:${gameDate}`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/historical/closing_lines`,
|
||||
{ sport: key, date: gameDate },
|
||||
cacheKey
|
||||
);
|
||||
if (!data) return [];
|
||||
const raw = data.lines || data.results || data.data || [];
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
getHistoricalProps,
|
||||
getClosingLines,
|
||||
__internals: { limiter, breaker, SPORT_KEYS, BASE_URL, normalizeHistoricalProp },
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* PropOdds — player-prop specialist. Consensus source #2 alongside SharpAPI.
|
||||
*
|
||||
* Strict free-tier monthly limits — use sparingly. Specialized for the exact
|
||||
* lane we live in: player props.
|
||||
*
|
||||
* When called: during grading, AFTER SharpAPI, to get a second consensus
|
||||
* data point. Three-way consensus (SharpAPI + PropOdds + OddsPapi) is a
|
||||
* stronger signal than two-way for the line-divergence trap.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
const { devig } = require('../../utils/odds');
|
||||
|
||||
const SOURCE = 'propodds';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_SECONDS = 90; // odds are time-sensitive but rarer fetch
|
||||
const STALE_CACHE_TTL_SECONDS = 300;
|
||||
|
||||
const BASE_URL = process.env.PROPODDS_BASE_URL || 'https://api.prop-odds.com/v1';
|
||||
|
||||
// 3 req/min — strict because the free monthly cap is low.
|
||||
const limiter = createLimiter({ tokensPerInterval: 3, interval: 60_000 });
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
const SPORT_KEYS = Object.freeze({
|
||||
nba: 'nba',
|
||||
wnba: 'wnba',
|
||||
mlb: 'mlb',
|
||||
nfl: 'nfl',
|
||||
nhl: 'nhl',
|
||||
ncaab: 'ncaab',
|
||||
ncaafb: 'ncaaf',
|
||||
});
|
||||
|
||||
function configured() {
|
||||
return !!process.env.PROPODDS_KEY;
|
||||
}
|
||||
|
||||
function sportKey(sport) {
|
||||
const key = SPORT_KEYS[sport];
|
||||
if (!key) throw new Error(`Unsupported sport: ${sport}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function fetchWithGuards(url, params, cacheKey) {
|
||||
if (!configured()) return null;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached && !cached.stale) return cached;
|
||||
|
||||
await limiter.waitForToken();
|
||||
try {
|
||||
const data = await breaker.call(async () => {
|
||||
const res = await axios.get(url, {
|
||||
params: { ...params, api_key: process.env.PROPODDS_KEY },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const err = new Error('propodds rate limited');
|
||||
err.code = 'PROPODDS_429';
|
||||
throw err;
|
||||
}
|
||||
return res.data;
|
||||
});
|
||||
await cacheSet(cacheKey, data, CACHE_TTL_SECONDS);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err?.code === 'PROPODDS_429' && cached) {
|
||||
const stale = { ...cached, stale: true };
|
||||
await cacheSet(cacheKey, stale, STALE_CACHE_TTL_SECONDS);
|
||||
return stale;
|
||||
}
|
||||
if (err?.code === 'CIRCUIT_OPEN') return cached ? { ...cached, stale: true } : null;
|
||||
console.warn(`[${SOURCE}] fetch failed for ${cacheKey}:`, err?.message);
|
||||
return cached ? { ...cached, stale: true } : null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProp(raw) {
|
||||
const overOdds = raw.over_odds ?? raw.over_price ?? null;
|
||||
const underOdds = raw.under_odds ?? raw.under_price ?? null;
|
||||
const fair = (overOdds != null && underOdds != null) ? devig(overOdds, underOdds) : null;
|
||||
return {
|
||||
book: raw.book ?? raw.bookmaker ?? null,
|
||||
player: raw.player ?? raw.player_name ?? null,
|
||||
statType: raw.market ?? raw.stat_type ?? null,
|
||||
line: Number(raw.line ?? raw.point ?? null),
|
||||
overOdds,
|
||||
underOdds,
|
||||
fairOver: fair?.fairOver ?? null,
|
||||
fairUnder: fair?.fairUnder ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getPlayerProps(sport, gameId, player, statType) {
|
||||
const key = sportKey(sport);
|
||||
const cacheKey = `propodds:${sport}:${gameId}:${player || 'all'}:${statType || 'all'}`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/sports/${key}/games/${gameId}/odds`,
|
||||
{ player, market: statType },
|
||||
cacheKey
|
||||
);
|
||||
if (!data) return [];
|
||||
const raw = data.props || data.markets || data.data || [];
|
||||
const normalized = Array.isArray(raw) ? raw.map(normalizeProp) : [];
|
||||
return data.stale ? Object.assign(normalized, { stale: true }) : normalized;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
getPlayerProps,
|
||||
__internals: { limiter, breaker, SPORT_KEYS, BASE_URL, normalizeProp },
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* SharpAPI — PRIMARY real-time odds source.
|
||||
*
|
||||
* Called by the GRADING PIPELINE (n8n) before a game tips. NOT used by the
|
||||
* resolution poller — closing-line capture goes through OddsPapi for the
|
||||
* Pinnacle benchmark.
|
||||
*
|
||||
* The adapter exposes three reads:
|
||||
* getPlayerProps — every player prop across books, de-vigged
|
||||
* getGameOdds — spread / total / moneyline for one game
|
||||
* getConsensusLine — median / min / max line across books (trap detector)
|
||||
*
|
||||
* Free-tier budget is 12 req/min. We cap at 10 to leave headroom for
|
||||
* incident retries. Responses cache in Redis for 60s — long enough to
|
||||
* coalesce duplicate grade requests for the same prop, short enough that a
|
||||
* line move propagates inside a minute.
|
||||
*
|
||||
* Failure modes:
|
||||
* 429 — back off, serve stale cache marked `{ stale: true }`
|
||||
* 5xx — circuit breaker (3 fails → open 60s)
|
||||
* timeout — 10s connect/read, circuit breaker counts it as a failure
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../utils/rateLimiter');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
const { devig } = require('../../utils/odds');
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
|
||||
const SOURCE = 'sharpapi';
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_SECONDS = 60;
|
||||
const STALE_CACHE_TTL_SECONDS = 300;
|
||||
|
||||
const BASE_URL = process.env.SHARPAPI_BASE_URL || 'https://api.sharpapi.com/v1';
|
||||
|
||||
const SPORT_KEYS = Object.freeze({
|
||||
nba: 'basketball_nba',
|
||||
wnba: 'basketball_wnba',
|
||||
mlb: 'baseball_mlb',
|
||||
nfl: 'americanfootball_nfl',
|
||||
nhl: 'icehockey_nhl',
|
||||
ncaab: 'basketball_ncaab',
|
||||
ncaafb: 'americanfootball_ncaaf',
|
||||
});
|
||||
|
||||
const limiter = createLimiter(API_BUDGETS.sharpApi);
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
function configured() {
|
||||
return !!process.env.SHARPAPI_KEY;
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return { 'X-Api-Key': process.env.SHARPAPI_KEY };
|
||||
}
|
||||
|
||||
function sportKey(sport) {
|
||||
const key = SPORT_KEYS[sport];
|
||||
if (!key) throw new Error(`Unsupported sport: ${sport}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
function median(nums) {
|
||||
if (!nums.length) return null;
|
||||
const sorted = [...nums].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
async function fetchWithGuards(url, params, cacheKey) {
|
||||
if (!configured()) return null;
|
||||
|
||||
// 1. Hot cache — fresh hit returns immediately.
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached && !cached.stale) return cached;
|
||||
|
||||
await limiter.waitForToken();
|
||||
|
||||
try {
|
||||
const data = await breaker.call(async () => {
|
||||
const res = await axios.get(url, {
|
||||
params,
|
||||
headers: authHeaders(),
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const err = new Error('sharpapi rate limited');
|
||||
err.code = 'SHARPAPI_429';
|
||||
throw err;
|
||||
}
|
||||
return res.data;
|
||||
});
|
||||
await cacheSet(cacheKey, data, CACHE_TTL_SECONDS);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err?.code === 'SHARPAPI_429' && cached) {
|
||||
// Serve stale cache marked so callers can decide whether to trust it.
|
||||
const stale = { ...cached, stale: true };
|
||||
await cacheSet(cacheKey, stale, STALE_CACHE_TTL_SECONDS);
|
||||
return stale;
|
||||
}
|
||||
if (err?.code === 'CIRCUIT_OPEN') {
|
||||
// Don't spam logs while the breaker is open — one warn per minute is
|
||||
// enough; the snapshot tells ops the state.
|
||||
return cached ? { ...cached, stale: true } : null;
|
||||
}
|
||||
console.warn(`[sharpapi] fetch failed for ${cacheKey}:`, err?.message);
|
||||
return cached ? { ...cached, stale: true } : null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlayerProp(raw) {
|
||||
// Defensive shape — SharpAPI returns slightly different field names across
|
||||
// markets. We only surface the fields downstream consumers actually need.
|
||||
const overOdds = raw.over_price ?? raw.overOdds ?? null;
|
||||
const underOdds = raw.under_price ?? raw.underOdds ?? null;
|
||||
const fair = (overOdds != null && underOdds != null) ? devig(overOdds, underOdds) : null;
|
||||
return {
|
||||
book: raw.book ?? raw.bookmaker ?? null,
|
||||
player: raw.player ?? raw.player_name ?? null,
|
||||
statType: raw.stat_type ?? raw.market ?? null,
|
||||
line: Number(raw.line ?? raw.point ?? null),
|
||||
overOdds,
|
||||
underOdds,
|
||||
fairOver: fair?.fairOver ?? null,
|
||||
fairUnder: fair?.fairUnder ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Fire-and-forget snapshot writer. Persists each line we observe so we can
|
||||
// later detect reverse line movement + juice degradation. Never blocks the
|
||||
// caller — a Supabase outage must not stop the grading pipeline.
|
||||
function snapshotProps(sport, gameId, normalized, consensusMedian) {
|
||||
if (!normalized || normalized.length === 0) return;
|
||||
const rows = normalized
|
||||
.filter((p) => Number.isFinite(p.line))
|
||||
.map((p) => ({
|
||||
game_id: gameId,
|
||||
sport,
|
||||
player_name: p.player,
|
||||
player_id: null,
|
||||
stat_type: p.statType,
|
||||
line: p.line,
|
||||
over_odds: p.overOdds,
|
||||
under_odds: p.underOdds,
|
||||
book: p.book,
|
||||
consensus_median: consensusMedian ?? null,
|
||||
}));
|
||||
if (rows.length === 0) return;
|
||||
// Run after the response — Promise.resolve().then keeps it off the
|
||||
// caller's critical path without leaking unhandled rejections.
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { error } = await supabase.from('line_snapshots').insert(rows);
|
||||
if (error) console.warn('[sharpapi] snapshot insert failed:', error.message);
|
||||
} catch (err) {
|
||||
console.warn('[sharpapi] snapshot insert threw:', err?.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getPlayerProps(sport, gameId) {
|
||||
const key = sportKey(sport);
|
||||
const cacheKey = `odds:${sport}:${gameId}:player_props`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/sports/${key}/events/${gameId}/odds`,
|
||||
{ markets: 'player_props' },
|
||||
cacheKey
|
||||
);
|
||||
if (!data) return [];
|
||||
const raw = data.props || data.markets || data.data || [];
|
||||
const normalized = Array.isArray(raw) ? raw.map(normalizePlayerProp) : [];
|
||||
|
||||
// Only snapshot fresh data — stale-cache fallbacks are previously stored
|
||||
// already; re-snapshotting would mint duplicate "now" rows on every call.
|
||||
if (!data.stale && normalized.length) {
|
||||
snapshotProps(sport, gameId, normalized);
|
||||
}
|
||||
|
||||
return data.stale ? Object.assign(normalized, { stale: true }) : normalized;
|
||||
}
|
||||
|
||||
async function getGameOdds(sport, gameId) {
|
||||
const key = sportKey(sport);
|
||||
const cacheKey = `odds:${sport}:${gameId}:game`;
|
||||
const data = await fetchWithGuards(
|
||||
`${BASE_URL}/sports/${key}/events/${gameId}/odds`,
|
||||
{ markets: 'spreads,totals,h2h' },
|
||||
cacheKey
|
||||
);
|
||||
if (!data) return null;
|
||||
return {
|
||||
spread: data.spread ?? null,
|
||||
total: data.total ?? null,
|
||||
moneyline: data.h2h ?? data.moneyline ?? null,
|
||||
stale: !!data.stale,
|
||||
};
|
||||
}
|
||||
|
||||
async function getConsensusLine(sport, gameId, playerName, statType) {
|
||||
const props = await getPlayerProps(sport, gameId);
|
||||
const matches = props.filter(
|
||||
(p) => p.player && p.statType
|
||||
&& p.player.toLowerCase() === playerName.toLowerCase()
|
||||
&& p.statType === statType
|
||||
&& Number.isFinite(p.line)
|
||||
);
|
||||
if (!matches.length) return null;
|
||||
const lines = matches.map((p) => p.line);
|
||||
return {
|
||||
median: median(lines),
|
||||
min: Math.min(...lines),
|
||||
max: Math.max(...lines),
|
||||
bookCount: matches.length,
|
||||
stale: !!props.stale,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configured,
|
||||
getPlayerProps,
|
||||
getGameOdds,
|
||||
getConsensusLine,
|
||||
// Exported for tests so they can poke the circuit breaker / limiter state.
|
||||
__internals: { limiter, breaker, SPORT_KEYS },
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Normal CDF using rational approximation (Abramowitz & Stegun).
|
||||
*/
|
||||
function normalCDF(x, mean = 0, stddev = 1) {
|
||||
if (stddev <= 0) return x >= mean ? 1 : 0;
|
||||
const z = (x - mean) / stddev;
|
||||
const t = 1 / (1 + 0.2316419 * Math.abs(z));
|
||||
const d = 0.3989422804014327; // 1/sqrt(2*pi)
|
||||
const p = d * Math.exp(-z * z / 2) *
|
||||
(t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.8212560 + t * 1.3302744)))));
|
||||
return z > 0 ? 1 - p : p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate model probability for a prop line using normal distribution.
|
||||
* @param {number} mean - Projected mean
|
||||
* @param {number} stddev - Standard deviation
|
||||
* @param {number} line - The prop line
|
||||
* @param {string} direction - 'over' or 'under'
|
||||
* @returns {number} Probability 0-1
|
||||
*/
|
||||
function calculateModelProbability(mean, stddev, line, direction) {
|
||||
if (stddev <= 0) {
|
||||
if (direction === 'over') return mean > line ? 1 : 0;
|
||||
return mean < line ? 1 : 0;
|
||||
}
|
||||
|
||||
const cdf = normalCDF(line, mean, stddev);
|
||||
return direction === 'over' ? 1 - cdf : cdf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert American odds to implied probability.
|
||||
* @param {number} odds - American odds (e.g. -110, +150)
|
||||
* @returns {number} Implied probability 0-1
|
||||
*/
|
||||
function americanToImplied(odds) {
|
||||
if (odds < 0) return Math.abs(odds) / (Math.abs(odds) + 100);
|
||||
return 100 / (odds + 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare model probability to book implied probability.
|
||||
* @param {number} modelProb - Model-calculated probability
|
||||
* @param {number} bookOdds - American odds from the book
|
||||
* @returns {object} { model_prob, book_implied, edge, value_detected }
|
||||
*/
|
||||
function compareToBookImplied(modelProb, bookOdds) {
|
||||
const bookImplied = americanToImplied(bookOdds);
|
||||
const edge = modelProb - bookImplied;
|
||||
|
||||
return {
|
||||
model_prob: Math.round(modelProb * 1000) / 1000,
|
||||
book_implied: Math.round(bookImplied * 1000) / 1000,
|
||||
edge: Math.round(edge * 1000) / 1000,
|
||||
value_detected: edge > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan alternate lines for A-grade props to find optimal value.
|
||||
* @param {object} prop - { player, stat, projected_mean, projected_stddev, grade }
|
||||
* @param {Array} oddsData - Array of { line, odds, book } from alt markets
|
||||
* @returns {object|null} Best alt line with edge, or null
|
||||
*/
|
||||
function scanAltLines(prop, oddsData) {
|
||||
if (!prop || !oddsData || oddsData.length === 0) return null;
|
||||
|
||||
const { projected_mean, projected_stddev } = prop;
|
||||
const direction = prop.direction || 'over';
|
||||
|
||||
const evaluated = oddsData.map(alt => {
|
||||
const modelProb = calculateModelProbability(projected_mean, projected_stddev, alt.line, direction);
|
||||
const comparison = compareToBookImplied(modelProb, alt.odds);
|
||||
|
||||
return {
|
||||
line: alt.line,
|
||||
odds: alt.odds,
|
||||
book: alt.book,
|
||||
model_probability: comparison.model_prob,
|
||||
book_implied: comparison.book_implied,
|
||||
edge: comparison.edge,
|
||||
value_detected: comparison.value_detected,
|
||||
};
|
||||
});
|
||||
|
||||
const withValue = evaluated.filter(e => e.value_detected);
|
||||
if (withValue.length === 0) return null;
|
||||
|
||||
withValue.sort((a, b) => b.edge - a.edge);
|
||||
const optimal = withValue[0];
|
||||
|
||||
return {
|
||||
optimal_line: optimal.line,
|
||||
odds: optimal.odds,
|
||||
book: optimal.book,
|
||||
model_probability: optimal.model_probability,
|
||||
book_implied: optimal.book_implied,
|
||||
edge: optimal.edge,
|
||||
all_value_lines: withValue,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scanAltLines,
|
||||
calculateModelProbability,
|
||||
compareToBookImplied,
|
||||
normalCDF,
|
||||
americanToImplied,
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
const DISTRIBUTION_SHAPES = {
|
||||
points: 'normal',
|
||||
rebounds: 'normal',
|
||||
assists: 'normal',
|
||||
home_runs: 'negative_binomial',
|
||||
stolen_bases: 'negative_binomial',
|
||||
pitcher_strikeouts: 'bimodal_mixture',
|
||||
walks: 'poisson',
|
||||
hits: 'normal',
|
||||
total_bases: 'normal',
|
||||
rbis: 'normal',
|
||||
runs_scored: 'poisson',
|
||||
strikeouts_batter: 'poisson',
|
||||
earned_runs: 'poisson',
|
||||
outs_recorded: 'normal',
|
||||
walks_allowed: 'poisson',
|
||||
hits_allowed: 'normal',
|
||||
pitches_thrown: 'normal',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the distribution shape for a stat type.
|
||||
* @param {string} statType
|
||||
* @returns {string} Distribution shape name
|
||||
*/
|
||||
function getDistributionShape(statType) {
|
||||
return DISTRIBUTION_SHAPES[statType] || 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normal CDF using rational approximation.
|
||||
*/
|
||||
function normalCDF(x, mean, stddev) {
|
||||
if (stddev <= 0) return x >= mean ? 1 : 0;
|
||||
const z = (x - mean) / stddev;
|
||||
const t = 1 / (1 + 0.2316419 * Math.abs(z));
|
||||
const d = 0.3989422804014327;
|
||||
const p = d * Math.exp(-z * z / 2) *
|
||||
(t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.8212560 + t * 1.3302744)))));
|
||||
return z > 0 ? 1 - p : p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poisson CDF: P(X <= x) for Poisson(lambda).
|
||||
* @param {number} x - Value (floored to integer)
|
||||
* @param {number} lambda - Rate parameter
|
||||
* @returns {number} Cumulative probability
|
||||
*/
|
||||
function poissonCDF(x, lambda) {
|
||||
if (lambda <= 0) return 1;
|
||||
const k = Math.floor(x);
|
||||
if (k < 0) return 0;
|
||||
|
||||
let cdf = 0;
|
||||
let term = Math.exp(-lambda);
|
||||
cdf += term;
|
||||
|
||||
for (let i = 1; i <= k; i++) {
|
||||
term *= lambda / i;
|
||||
cdf += term;
|
||||
}
|
||||
|
||||
return Math.min(1, cdf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative Binomial CDF: P(X <= x) for NB(r, p).
|
||||
* Uses direct summation of PMF.
|
||||
* @param {number} x - Value (floored to integer)
|
||||
* @param {number} r - Number of successes
|
||||
* @param {number} p - Probability of success per trial
|
||||
* @returns {number} Cumulative probability
|
||||
*/
|
||||
function negativeBinomialCDF(x, r, p) {
|
||||
if (r <= 0 || p <= 0 || p > 1) return 0;
|
||||
const k = Math.floor(x);
|
||||
if (k < 0) return 0;
|
||||
|
||||
let cdf = 0;
|
||||
|
||||
// log of binomial coefficient using lgamma approximation
|
||||
function logGamma(z) {
|
||||
// Stirling approximation for lgamma
|
||||
if (z < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * z)) - logGamma(1 - z);
|
||||
z -= 1;
|
||||
const coeffs = [
|
||||
76.18009172947146, -86.50532032941677, 24.01409824083091,
|
||||
-1.231739572450155, 0.001208650973866179, -0.000005395239384953,
|
||||
];
|
||||
let x = 0.99999999999980993;
|
||||
for (let i = 0; i < coeffs.length; i++) {
|
||||
x += coeffs[i] / (z + i + 1);
|
||||
}
|
||||
const t = z + coeffs.length - 0.5;
|
||||
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
|
||||
}
|
||||
|
||||
for (let i = 0; i <= k; i++) {
|
||||
const logCoeff = logGamma(i + r) - logGamma(i + 1) - logGamma(r);
|
||||
const logProb = logCoeff + r * Math.log(p) + i * Math.log(1 - p);
|
||||
cdf += Math.exp(logProb);
|
||||
}
|
||||
|
||||
return Math.min(1, Math.max(0, cdf));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate probability based on distribution shape.
|
||||
* @param {string} shape - Distribution type
|
||||
* @param {object} params - Distribution parameters
|
||||
* @param {number} line - Prop line
|
||||
* @param {string} direction - 'over' or 'under'
|
||||
* @returns {number} Probability 0-1
|
||||
*/
|
||||
function calculateProbability(shape, params, line, direction) {
|
||||
let cdf;
|
||||
|
||||
switch (shape) {
|
||||
case 'normal':
|
||||
cdf = normalCDF(line, params.mean, params.stddev);
|
||||
break;
|
||||
case 'poisson':
|
||||
cdf = poissonCDF(line, params.lambda);
|
||||
break;
|
||||
case 'negative_binomial':
|
||||
cdf = negativeBinomialCDF(line, params.r, params.p);
|
||||
break;
|
||||
case 'bimodal_mixture':
|
||||
// Weighted mixture of two normals
|
||||
const w1 = params.weight1 || 0.5;
|
||||
const cdf1 = normalCDF(line, params.mean1, params.stddev1);
|
||||
const cdf2 = normalCDF(line, params.mean2, params.stddev2);
|
||||
cdf = w1 * cdf1 + (1 - w1) * cdf2;
|
||||
break;
|
||||
default:
|
||||
cdf = normalCDF(line, params.mean, params.stddev);
|
||||
}
|
||||
|
||||
return direction === 'over' ? 1 - cdf : cdf;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DISTRIBUTION_SHAPES,
|
||||
getDistributionShape,
|
||||
calculateProbability,
|
||||
normalCDF,
|
||||
poissonCDF,
|
||||
negativeBinomialCDF,
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Per-upstream circuit breaker.
|
||||
*
|
||||
* States:
|
||||
* CLOSED — calls flow normally
|
||||
* OPEN — calls short-circuit instantly with BreakerOpenError
|
||||
* HALF_OPEN — one trial call allowed; success closes, failure re-opens
|
||||
*
|
||||
* Tunable per key; defaults are conservative (open after 5 fails in 60s,
|
||||
* stay open for 30s). Set per-source thresholds in OVERRIDES.
|
||||
*/
|
||||
|
||||
const STATES = Object.freeze({ CLOSED: 'CLOSED', OPEN: 'OPEN', HALF_OPEN: 'HALF_OPEN' });
|
||||
|
||||
const DEFAULTS = {
|
||||
failureThreshold: 5,
|
||||
windowMs: 60_000,
|
||||
cooldownMs: 30_000,
|
||||
};
|
||||
|
||||
const OVERRIDES = Object.freeze({
|
||||
pinnacle: { failureThreshold: 3, cooldownMs: 60_000 },
|
||||
'nba-stats': { failureThreshold: 4, cooldownMs: 45_000 },
|
||||
pybaseball: { failureThreshold: 3, cooldownMs: 60_000 },
|
||||
});
|
||||
|
||||
class BreakerOpenError extends Error {
|
||||
constructor(key, retryAt) {
|
||||
super(`circuit open for ${key}`);
|
||||
this.name = 'BreakerOpenError';
|
||||
this.code = 'BREAKER_OPEN';
|
||||
this.upstream = key;
|
||||
this.retryAt = retryAt;
|
||||
}
|
||||
}
|
||||
|
||||
const breakers = new Map();
|
||||
|
||||
function getBreaker(key) {
|
||||
let b = breakers.get(key);
|
||||
if (!b) {
|
||||
const cfg = { ...DEFAULTS, ...(OVERRIDES[key] || {}) };
|
||||
b = {
|
||||
key,
|
||||
state: STATES.CLOSED,
|
||||
failures: [],
|
||||
openedAt: 0,
|
||||
cfg,
|
||||
};
|
||||
breakers.set(key, b);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function pruneFailures(b, now) {
|
||||
const cutoff = now - b.cfg.windowMs;
|
||||
while (b.failures.length && b.failures[0] < cutoff) b.failures.shift();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` under the breaker. If the breaker is OPEN, throws immediately.
|
||||
* Counts thrown errors as failures, except those marked `err.skipBreaker`.
|
||||
*/
|
||||
async function call(key, fn) {
|
||||
const b = getBreaker(key);
|
||||
const now = Date.now();
|
||||
|
||||
if (b.state === STATES.OPEN) {
|
||||
const reopenAt = b.openedAt + b.cfg.cooldownMs;
|
||||
if (now < reopenAt) throw new BreakerOpenError(key, reopenAt);
|
||||
// Cooldown elapsed — give one trial call.
|
||||
b.state = STATES.HALF_OPEN;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
if (b.state === STATES.HALF_OPEN) {
|
||||
b.state = STATES.CLOSED;
|
||||
b.failures.length = 0;
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (!err || !err.skipBreaker) {
|
||||
b.failures.push(Date.now());
|
||||
pruneFailures(b, Date.now());
|
||||
if (b.state === STATES.HALF_OPEN || b.failures.length >= b.cfg.failureThreshold) {
|
||||
b.state = STATES.OPEN;
|
||||
b.openedAt = Date.now();
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const out = {};
|
||||
for (const [k, b] of breakers.entries()) {
|
||||
pruneFailures(b, Date.now());
|
||||
out[k] = {
|
||||
state: b.state,
|
||||
failures: b.failures.length,
|
||||
cooldownEndsAt: b.state === STATES.OPEN ? b.openedAt + b.cfg.cooldownMs : null,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function reset(key) {
|
||||
if (key) breakers.delete(key);
|
||||
else breakers.clear();
|
||||
}
|
||||
|
||||
module.exports = { call, snapshot, reset, BreakerOpenError, STATES };
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Thin client for the local share-card endpoint.
|
||||
*
|
||||
* In-process callers can just call the renderer directly; we hit the route
|
||||
* so caching + rate-limit semantics stay consistent with how external
|
||||
* channels (n8n, email) will request the same cards.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
const API_BASE = process.env.SHARE_CARD_API_BASE || process.env.API_BASE_URL || 'http://localhost:4000';
|
||||
const TIMEOUT_MS = 12_000;
|
||||
|
||||
async function buildCard({ type, format = 'twitter', payload, raw = false }) {
|
||||
const res = await axios.post(`${API_BASE}/api/share-card${raw ? '?svg=1' : ''}`, { type, format, ...payload }, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: TIMEOUT_MS,
|
||||
validateStatus: (s) => s >= 200 && s < 500,
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
const err = new Error(`share-card responded ${res.status}`);
|
||||
err.detail = res.data?.toString?.('utf8');
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
buffer: Buffer.from(res.data),
|
||||
contentType: res.headers['content-type'] || 'image/png',
|
||||
cached: res.headers['x-cache'] === 'HIT',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a public URL where a card lives (or will be once the static-CDN
|
||||
* adapter is wired). Today the card is consumed directly by Telegram /
|
||||
* Discord / email so we don't always need a hosted URL — but generators
|
||||
* return one so downstream callers can render `<img src=...>` markup.
|
||||
*/
|
||||
function publicCardUrl({ type, format = 'twitter', payload }) {
|
||||
const qs = new URLSearchParams({
|
||||
type, format,
|
||||
p: Buffer.from(JSON.stringify(payload)).toString('base64url'),
|
||||
}).toString();
|
||||
const base = process.env.PUBLIC_SHARE_CARD_BASE || `${API_BASE}/api/share-card`;
|
||||
return `${base}?${qs}`;
|
||||
}
|
||||
|
||||
module.exports = { buildCard, publicCardUrl };
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Cascade alert formatter.
|
||||
*
|
||||
* Triggered by CascadeEngine when an injury / lineup / weather delta
|
||||
* recomputes a slate of grades. Composes the broadcast text + image
|
||||
* downstream channels send.
|
||||
*/
|
||||
|
||||
const { buildCard, publicCardUrl } = require('./_shareCardClient');
|
||||
|
||||
const EMOJI_BY_TRIGGER = Object.freeze({
|
||||
injury: '🚨',
|
||||
lineup: '🔁',
|
||||
weather: '☔',
|
||||
ref: '🟨',
|
||||
umpire: '🟨',
|
||||
manual: '🟢',
|
||||
});
|
||||
|
||||
function urgencyFor(affectedCount) {
|
||||
if (affectedCount >= 5) return 'high';
|
||||
if (affectedCount >= 2) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function shortTrigger(detail) {
|
||||
// Best-effort headline: prefer player + status if present, otherwise
|
||||
// type-specific defaults. Keep under 60 chars for the subject line.
|
||||
if (!detail || typeof detail !== 'object') return 'event';
|
||||
if (detail.player && detail.status) return `${detail.player} ${detail.status}`;
|
||||
if (detail.player) return detail.player;
|
||||
if (detail.summary) return String(detail.summary).slice(0, 60);
|
||||
return detail.type || 'event';
|
||||
}
|
||||
|
||||
async function format(alert) {
|
||||
const triggerType = alert.trigger_type || 'manual';
|
||||
const emoji = EMOJI_BY_TRIGGER[triggerType] || '🟢';
|
||||
const affected = Array.isArray(alert.affected_props) ? alert.affected_props : [];
|
||||
const count = affected.length;
|
||||
const headline = shortTrigger(alert.trigger_detail);
|
||||
|
||||
const text = `${emoji} ${headline.toUpperCase()} — ${count} prop${count === 1 ? '' : 's'} affected`;
|
||||
|
||||
// Render the cascade as a "recap" card so the layout is consistent.
|
||||
// Negative deltas (grade went down) render as miss-tinted rows, positives
|
||||
// as hit-tinted. This is purely visual; the data layer keeps the deltas.
|
||||
const entries = affected.slice(0, 6).map((p) => ({
|
||||
player: p.player || '',
|
||||
stat: p.stat || '',
|
||||
direction: p.direction || 'over',
|
||||
line: p.line,
|
||||
grade: p.new_grade || p.grade || '—',
|
||||
result: (p.new_grade && p.old_grade && rank(p.new_grade) > rank(p.old_grade)) ? 'miss' : 'hit',
|
||||
}));
|
||||
|
||||
const payload = {
|
||||
date: `${emoji} ${headline}`.slice(0, 40),
|
||||
accuracy: null,
|
||||
entries,
|
||||
};
|
||||
|
||||
let card = null;
|
||||
try {
|
||||
card = await buildCard({ type: 'recap', format: 'square', payload });
|
||||
} catch (err) {
|
||||
console.error('[cascadeFormatter] card build failed:', err.message);
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
urgency: urgencyFor(count),
|
||||
affected_count: count,
|
||||
imageBuffer: card?.buffer || null,
|
||||
imageUrl: publicCardUrl({ type: 'recap', format: 'square', payload }),
|
||||
};
|
||||
}
|
||||
|
||||
function rank(grade) {
|
||||
const map = { 'A+': 0, 'A': 1, 'A-': 2, 'B+': 3, 'B': 4, 'B-': 5, 'C+': 6, 'C': 7, 'C-': 8, 'D': 9, 'F': 10 };
|
||||
return map[grade] ?? 5;
|
||||
}
|
||||
|
||||
module.exports = { format, urgencyFor };
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Cheatsheet generator — runs daily ~4:30 PM ET via n8n.
|
||||
*
|
||||
* 1. Pull top-graded props for tonight (default limit 8).
|
||||
* 2. Synthesize structured cheatsheet payload.
|
||||
* 3. Build a share card via the local share-card endpoint.
|
||||
* 4. Return { data, imageUrl, imageBuffer } so downstream consumers
|
||||
* (email, telegram, discord) can fan out.
|
||||
*
|
||||
* No I/O side effects. Caller decides what to push where.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { buildCard, publicCardUrl } = require('./_shareCardClient');
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost:4000';
|
||||
|
||||
async function fetchTopGraded(limit = 8, sport = null) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (sport) params.set('sport', sport);
|
||||
const res = await axios.get(`${API_BASE}/api/props/top-graded?${params}`, { timeout: 10_000 });
|
||||
return Array.isArray(res.data?.props) ? res.data.props : [];
|
||||
}
|
||||
|
||||
function todayLabel(now = new Date()) {
|
||||
return now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'America/New_York' });
|
||||
}
|
||||
|
||||
async function generate({ limit = 8, sport = null, format = 'square' } = {}) {
|
||||
const grades = await fetchTopGraded(limit, sport);
|
||||
const payload = {
|
||||
date: todayLabel(),
|
||||
gameCount: new Set(grades.map((g) => g.game_id)).size,
|
||||
grades: grades.map((g) => ({
|
||||
grade: g.grade,
|
||||
player: g.player_name || g.player,
|
||||
stat: g.stat_type || g.stat,
|
||||
direction: g.direction,
|
||||
line: g.line,
|
||||
})),
|
||||
};
|
||||
|
||||
let card = null;
|
||||
try {
|
||||
card = await buildCard({ type: 'cheatsheet', format, payload });
|
||||
} catch (err) {
|
||||
// Don't sink the generator if the card can't render — surface the data,
|
||||
// let the caller decide whether to push text-only.
|
||||
console.error('[cheatsheetGenerator] card build failed:', err.message);
|
||||
}
|
||||
|
||||
return {
|
||||
data: payload,
|
||||
imageBuffer: card?.buffer || null,
|
||||
imageContentType: card?.contentType || null,
|
||||
imageUrl: publicCardUrl({ type: 'cheatsheet', format, payload }),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Grade of the Day selector — runs daily ~5:15 PM ET.
|
||||
*
|
||||
* Rules:
|
||||
* 1. Use an A+ if one exists tonight.
|
||||
* 2. Otherwise the single highest-confidence A or A-.
|
||||
* 3. Otherwise fall back to the top grade overall.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { buildCard, publicCardUrl } = require('./_shareCardClient');
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost:4000';
|
||||
const GRADE_RANK = { 'A+': 0, 'A': 1, 'A-': 2, 'B+': 3, 'B': 4, 'B-': 5, 'C+': 6, 'C': 7, 'C-': 8, 'D': 9, 'F': 10 };
|
||||
|
||||
async function fetchTop(limit = 8) {
|
||||
const res = await axios.get(`${API_BASE}/api/props/top-graded?limit=${limit}`, { timeout: 10_000 }).catch(() => null);
|
||||
return Array.isArray(res?.data?.props) ? res.data.props : [];
|
||||
}
|
||||
|
||||
function pickGOTD(props) {
|
||||
if (!props.length) return null;
|
||||
const sorted = [...props].sort((a, b) => {
|
||||
const ra = GRADE_RANK[a.grade] ?? 99;
|
||||
const rb = GRADE_RANK[b.grade] ?? 99;
|
||||
if (ra !== rb) return ra - rb;
|
||||
return (b.confidence ?? 0) - (a.confidence ?? 0);
|
||||
});
|
||||
return sorted[0];
|
||||
}
|
||||
|
||||
async function generate({ format = 'square' } = {}) {
|
||||
const props = await fetchTop(8);
|
||||
const winner = pickGOTD(props);
|
||||
if (!winner) {
|
||||
return { data: null, imageBuffer: null, imageUrl: null, note: 'no grades available' };
|
||||
}
|
||||
|
||||
const payload = {
|
||||
player: winner.player_name || winner.player,
|
||||
sport: winner.sport,
|
||||
stat: winner.stat_type || winner.stat,
|
||||
line: winner.line,
|
||||
direction: winner.direction,
|
||||
grade: winner.grade,
|
||||
projection: winner.projection,
|
||||
summary: winner.one_line_reason || winner.reasoning?.summary || null,
|
||||
};
|
||||
|
||||
let card = null;
|
||||
try {
|
||||
card = await buildCard({ type: 'gotd', format, payload });
|
||||
} catch (err) {
|
||||
console.error('[gradeOfTheDay] card build failed:', err.message);
|
||||
}
|
||||
|
||||
return {
|
||||
data: payload,
|
||||
imageBuffer: card?.buffer || null,
|
||||
imageContentType: card?.contentType || null,
|
||||
imageUrl: publicCardUrl({ type: 'gotd', format, payload }),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { generate, pickGOTD };
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Results generator — runs daily ~9 AM ET via n8n.
|
||||
*
|
||||
* 1. Query yesterday's resolved grades from the Ledger.
|
||||
* 2. Compute hits / misses / accuracy / CLV summary.
|
||||
* 3. Build a recap share card.
|
||||
* 4. Return { data, imageBuffer, imageUrl } for downstream channels.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { buildCard, publicCardUrl } = require('./_shareCardClient');
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost:4000';
|
||||
|
||||
function yesterdayISO() {
|
||||
const d = new Date(Date.now() - 24 * 60 * 60_000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dateLabel(iso) {
|
||||
return new Date(`${iso}T12:00:00Z`).toLocaleDateString('en-US', {
|
||||
month: 'long', day: 'numeric', year: 'numeric', timeZone: 'America/New_York',
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchLedgerForDate(date) {
|
||||
const res = await axios.get(`${API_BASE}/api/ledger?date=${date}`, { timeout: 10_000 }).catch(() => null);
|
||||
const rows = Array.isArray(res?.data?.entries) ? res.data.entries : [];
|
||||
return rows;
|
||||
}
|
||||
|
||||
function summarize(entries) {
|
||||
const hits = entries.filter((e) => e.result === 'hit').length;
|
||||
const misses = entries.filter((e) => e.result === 'miss').length;
|
||||
const total = hits + misses;
|
||||
return {
|
||||
total,
|
||||
hits,
|
||||
misses,
|
||||
accuracy: total ? Math.round((hits / total) * 100) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function generate({ format = 'square', date = null } = {}) {
|
||||
const iso = date || yesterdayISO();
|
||||
const raw = await fetchLedgerForDate(iso);
|
||||
const stats = summarize(raw);
|
||||
|
||||
const payload = {
|
||||
date: dateLabel(iso),
|
||||
accuracy: stats.accuracy,
|
||||
entries: raw.slice(0, 6).map((e) => ({
|
||||
player: e.player_name || e.player,
|
||||
stat: e.stat_type || e.stat,
|
||||
direction: e.direction,
|
||||
line: e.line,
|
||||
grade: e.grade,
|
||||
result: e.result,
|
||||
})),
|
||||
};
|
||||
|
||||
let card = null;
|
||||
try {
|
||||
card = await buildCard({ type: 'recap', format, payload });
|
||||
} catch (err) {
|
||||
console.error('[resultsGenerator] card build failed:', err.message);
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...payload, ...stats },
|
||||
imageBuffer: card?.buffer || null,
|
||||
imageContentType: card?.contentType || null,
|
||||
imageUrl: publicCardUrl({ type: 'recap', format, payload }),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { generate };
|
||||
@@ -0,0 +1,22 @@
|
||||
function phiCoefficient(p11, p10, p01, p00) {
|
||||
const n = p11 + p10 + p01 + p00;
|
||||
if (n === 0) return 0;
|
||||
const p1plus = p11 + p10;
|
||||
const pplus1 = p11 + p01;
|
||||
const p0plus = p01 + p00;
|
||||
const pplus0 = p10 + p00;
|
||||
const denom = Math.sqrt(p1plus * pplus1 * p0plus * pplus0);
|
||||
if (denom === 0) return 0;
|
||||
return (p11 * p00 - p10 * p01) / denom;
|
||||
}
|
||||
|
||||
function hasMinimumObservations(sampleSize, minimum = 100) {
|
||||
return sampleSize >= minimum;
|
||||
}
|
||||
|
||||
function calculateJuiceAdjustedEV(modelProb, stake = 110) {
|
||||
const payout = 100;
|
||||
return (modelProb * payout) - ((1 - modelProb) * stake);
|
||||
}
|
||||
|
||||
module.exports = { phiCoefficient, hasMinimumObservations, calculateJuiceAdjustedEV };
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Discord webhook push.
|
||||
*
|
||||
* One outgoing webhook per channel. No bot library, no gateway connection;
|
||||
* just a POST per message. Embeds render the share card via the `image`
|
||||
* field — Discord fetches the URL server-side, so the URL must be publicly
|
||||
* reachable (n8n can hand the share-card buffer to a CDN if needed).
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const FormData = require('form-data');
|
||||
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const VYNDR_GREEN = 0x00D4A0;
|
||||
|
||||
const WEBHOOKS = Object.freeze({
|
||||
daily: process.env.DISCORD_WEBHOOK_DAILY || '',
|
||||
results: process.env.DISCORD_WEBHOOK_RESULTS || '',
|
||||
alerts: process.env.DISCORD_WEBHOOK_ALERTS || '',
|
||||
rare: process.env.DISCORD_WEBHOOK_RARE || '',
|
||||
});
|
||||
|
||||
function webhookFor(channel) {
|
||||
const url = WEBHOOKS[channel];
|
||||
if (!url) return null;
|
||||
// Don't accept arbitrary user input — only the small set above.
|
||||
if (!url.startsWith('https://discord.com/api/webhooks/') &&
|
||||
!url.startsWith('https://discordapp.com/api/webhooks/')) {
|
||||
return null;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function postToDiscord(channel, { text, imageUrl, imageBuffer, color } = {}) {
|
||||
const url = webhookFor(channel);
|
||||
if (!url) return { ok: false, error: `no webhook for ${channel}` };
|
||||
|
||||
const embed = {
|
||||
description: text || '',
|
||||
color: color || VYNDR_GREEN,
|
||||
image: imageUrl ? { url: imageUrl } : undefined,
|
||||
footer: { text: 'VYNDR · vyndr.app' },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const payload = { username: 'VYNDR', embeds: [embed] };
|
||||
|
||||
try {
|
||||
if (imageBuffer && Buffer.isBuffer(imageBuffer)) {
|
||||
// Multipart with attached PNG. Discord renders attachments inline.
|
||||
const form = new FormData();
|
||||
form.append('payload_json', JSON.stringify({
|
||||
username: 'VYNDR',
|
||||
embeds: [{
|
||||
description: text || '',
|
||||
color: color || VYNDR_GREEN,
|
||||
image: { url: 'attachment://vyndr.png' },
|
||||
footer: { text: 'VYNDR · vyndr.app' },
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
}));
|
||||
form.append('file1', imageBuffer, { filename: 'vyndr.png', contentType: 'image/png' });
|
||||
await axios.post(url, form, {
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
headers: form.getHeaders(),
|
||||
maxContentLength: 8 * 1024 * 1024,
|
||||
maxBodyLength: 8 * 1024 * 1024,
|
||||
});
|
||||
return { ok: true, channel, mode: 'attachment' };
|
||||
}
|
||||
await axios.post(url, payload, { timeout: HTTP_TIMEOUT_MS });
|
||||
return { ok: true, channel, mode: 'embed' };
|
||||
} catch (err) {
|
||||
const detail = err?.response?.data || err?.message || 'unknown';
|
||||
console.error(`[discord:${channel}] push failed:`, detail);
|
||||
return { ok: false, error: typeof detail === 'string' ? detail : JSON.stringify(detail) };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { postToDiscord, webhookFor };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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 []
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user