Steps 1-6 — make "real opportunities at takeable prices" the engine, not a filter. 1. DE-VIG (src/utils/devig.js): two-way multiplicative de-vig strips the vig and returns fair prob + fair price per side + the overround. One side missing → fair UNAVAILABLE (null), never faked. Method noted in code + the `devig_method` field. 2. EV (devig.evPct): ev_pct = model prob × decimal − 1 at the graded side's ACTUAL price. This is the ranking signal now, replacing raw |model−consensus|. 3. TAKEABLE gate (src/config/valueEngine.js, TAKEABLE_ODDS_CEILING −160 .. +200, env-tunable): promoted surfaces only (hero/featured/alerts). The full board still shows everything; Parlay Lab exempt; JUICE_ODDS_FLOOR (−400) stays the absolute backstop underneath. Strict null-guard (Number(null)===0 would have made a missing price "takeable"). 4. VALUE flag: passes BOTH gates (takeable AND ev_pct ≥ VALUE_EV_THRESHOLD). Grade = read quality; value = the price pays you. Shipped in payloads. 5. HERO v2 (heroPropService): highest ev_pct among takeable A/B reads — a huge gap on a −900 line is trivia, not an opportunity. 6. VALUE TRIPLET: book_odds · fair_odds · model_odds on every read (snapshot, hero, scan — they all spread the grade). Handoff documents the fields; the rendering is Session-2 Design's job. All wired in analyzeViaEngine1's existing p_win/kelly block (real quantile probability × real book odds, or nothing). 33 new tests; suite 276/3306 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
VYNDR — Backend / Frontend Data Contract (BACKEND_HANDOFF.md)
Canonical frontend↔backend data contract for the Player Intelligence System. Every future session references this. Authored Session 44 from the shipped implementation (Sessions 42–44). When an endpoint or component shape changes, update this file in the same commit.
Naming: archetypes are VYNDR Originals (TORCH, BOMBER, ALPHA, …). The old descriptive labels live on each archetype as
legacyNameand are NEVER shown.
1. Archetypes (src/services/archetypeService.js)
classify(sport, stats) → { sport, primary, secondary|null, blend }
primary | secondary : {
name, legacyName, tag, sport, color (#hex), glyph (key),
description, propDNA: { reliable: string[], volatile: string[] }, education
}
blend : [{ archetype: <NAME>, weight: 0..1 }] // normalized, top 4, sums ~1
Roster (41): see ARCHETYPES registry. Colors are unique within a sport,
reused across sports. Frontend visual map (color/glyph/desc) is mirrored in
web/src/lib/archetypes.js — keep colors identical (a test enforces it).
- NBA (15): TORCH, CONDUCTOR, FORTRESS, ARTILLERY, SURGE, DUAL THREAT, CONNECTOR, FASTBREAK, PAINT BOSS, LOCKDOWN, SWITCHBOARD, ARCHITECT, PISTON, SENTINEL, IGNITER
- WNBA-unique (5): DISTRIBUTOR, SHIELD, RANGE, SPARK, ANCHOR (plus reused NBA)
- MLB (15): BOMBER, BRUSH, DRIVER, ALPHA, WHIFF, GHOST, HYBRID, FLEX, WORKHORSE, CATALYST, MIRROR, HAMMER, SINKER, BRIDGE, SWITCH
- Soccer (6): FINISHER, MAESTRO, TOWER, MOTOR, BLADE, WALL
Classifier input shapes:
- NBA/WNBA:
{ ppg, rpg, apg, bpg, spg, threes, usg, fg3a, pos, bench } - MLB hitter:
{ avg, hr, rbi, sb, ops, runs, k_rate, doubles } - MLB pitcher:
{ era, k9, whip, ip_per_start, saves, role: 'SP'|'RP'|'CL' }
2. Stats API (src/routes/stats.js, mounted at /api/stats)
All player-intelligence endpoints are public, rate-limited 60/min, and ALWAYS
return 200 with a valid (possibly empty) shape — the UI must never hard-fail.
Browser must hit the Next proxy under web/src/app/api/stats/..., never
Express directly.
GET /api/stats/player/:name?sport=nba
{
player, sport, team, found: boolean,
archetype: { primary, secondary, blend }, // §1
propDNA: { reliable, volatile },
education: string,
season: [{ k, v, lg? }], // display rows (mono)
last10: [{ d, opp, res?, stat }],
splits: [{ k, a, b }],
gradeHistory: [{ grade, prop, hit?, miss? }],
activeProps: [{ stat, line, side: 'O'|'U', grade, confidence }],
intel: [{ label, kind: 'form'|'grade'|'plain', value, score?, color }],
injury: { label, note, cascade } | null
}
found is true when real season stats OR tonight's graded props exist. MLB stats
come from mlbStatsAdapter.getPlayerStats(name); NBA/WNBA from nbaStatsClient
(degrades to found:false when the Python service is offline).
GET /api/stats/leaders?sport=mlb&stat=hits&limit=10
{ sport, stat|null, leaders: [{ player, team, stat, line, side, grade, confidence }] }
Source: the grades:{sport} cache (tonight's graded slate, by confidence desc).
GET /api/stats/game/:id?sport=nba
ESPN game summary (injuries, leaders, ESPN Bet odds, box score) or {error}.
GET /api/stats/lineup/:team?sport=mlb (Session 43)
{ sport, team, lineup: [{ player, position, battingOrder, projectedMinutes }] }
MLB returns the probable starting pitcher from the schedule.
GET /api/stats/depth/:team?sport=nba
{ sport, team, positions: [{ position, starter, backup, thirdString }] }
GET /api/stats/cascade/:player?sport=nba&team=SA
{ sport, player, cascade: [{ player, stat, delta: '+x%', reason }] }
Usage redistribution weighted by archetype (SURGE benefits most).
3. Grade Result Card (web/src/components/vyndr/GradeResultCard.tsx)
GradeResultData (built by web/src/lib/gradeAdapter.js mapScanToGradeResult):
core fields (player, team, sport, stat, line, side, grade, confidence, edge,
projection, signals[], killConditions[], books[], altLadder[]) plus optional
Player-Intelligence fields that self-hide when absent:
archetypeBlend?: [{ archetype, weight }]
propDNA?: { reliable: string[], volatile: string[] }
statContext?: { season?, last10?, vsOpp? }
vyndrIntel?: { form?, usage?, matchup?, rest? }
The engine (analyzeViaEngine1) attaches snake_case fields
(season_avg/last10_avg/form/usage/matchup_grade/rest, and when a
season line is available, archetype/archetype_blend/prop_dna); the adapter
maps them in. Archetype at grade time requires a multi-stat season line — until
the snapshot pipeline supplies it, the archetype strip stays hidden.
4. Game Card (web/src/components/vyndr/GameCard.tsx)
GameCardData core (id, sport, away/home, time, venue, lines[], …) plus:
playerStrips?: [{ player, team, archetype?: {primary, secondary?}, stats: [{label,value}], props: [{stat,line,side,grade}] }]
pitchers?: { away: {name, era, archetype?}, home: {name, era, archetype?} }
Built by slateAdapter.mapScheduleToGameCards (groupPropsByPlayer +
mapPitchers). The card prefers playerStrips (name once, horizontal) over
legacy per-prop rows. Book chips use web/src/lib/books.js brand colors.
Headshots (Wave 2A/2B): each enriched grade + player strip may carry
playerId (MLBAM), espnId (ESPN athlete id), and headshotUrl (a RESOLVED
absolute ESPN href). PlayerAvatar prefers headshotUrl (exact URL, never
404s) → else constructs from (sport, playerId|espnId) → else a team-colored
monogram. espnId/headshotUrl come from snapshotService: primarily the
per-player stats resolve, and — when that misses (the flaky NBA/WNBA fallback)
— from espnAthleteIndex.buildEspnAthleteIndex(sport), which harvests ESPN
schedule→summary/boxscore/leaders/injuries/roster feeds (free, cached, MLB→{}).
Soccer resolves ONLY via a direct headshotUrl (no constructed URL); absent →
honest monogram (a free API_FOOTBALL_KEY is the reliable soccer path, unwired).
5. Stat Strip (web/src/components/vyndr/StatStrip.tsx)
compact (game cards) | expanded (profile hero / grade result). HARD RULE: the
player name appears ONCE; stats flow horizontally in JetBrains Mono. Props render
inline with GradeBadge. onPlayerClick → /player/:name?sport= (lib/playerHref.js).
5a. Normalization is applied at the SNAPSHOT source (Session 48)
snapshotService.runSnapshot normalizes every grade's player name (de-dotted
display) and dedupes to ONE grade per nameKey|stat_type (highest confidence)
BEFORE writing grades:{sport} + snapshot:{sport}:latest. Every downstream
consumer (GameCard overlay, /api/snapshot, /leaders, profile activeProps)
therefore receives clean, merged names. New consumers don't need their own
normalization — but UI lists built from RAW odds (e.g. the scan player grid)
must group by nameKey + display normalizeName().display.
5b. Player name normalization (Session 46)
src/utils/playerName.js (+ identical web/src/lib/playerName.js) is the ONE
source of truth for comparing/deduping names. normalizeName(raw) →
{ display, key }: display strips periods + de-dots the suffix (keeps accents
- casing);
keyis accent-folded, lowercased, suffix-stripped for comparison. Used by snapshot grouping,slateAdapter(grade index + player strips), andplayerIntelServiceso "A.J. Ewing"/"AJ Ewing" and "Jazz Chisholm"/"Jazz Chisholm Jr." collapse to one player.
5c. MLB intel features (Session 46)
buildIntelFields (grade-card STAT CONTEXT + VYNDR INTELLIGENCE) reads
l5_avg/l10_avg/l20_avg/opp_rank_stat/rest_days from the feature vector.
MLB game logs are now wired into featureCache.gameLogFeatures via
mlbStatsAdapter.getPlayerStats (the old Python game-log path was NBA/WNBA-only,
so MLB props had no intel). buildIntelFields(features, { playerStats, projection })
also accepts fallbacks so partial intel still renders.
5d. MLB probable pitchers (Session 46)
GET /api/schedule/:sport/pitchers (MLB only) → { sport, date, games: [{ home:{team,pitcher,era}, away:{...} }] } from probablePitchers /
mlbStatsAdapter.getScheduleWithPitchers (the ESPN schedule lacks them). The
Slate builds a team→pitcher map (slateAdapter.buildPitcherMap /
pitchersForGameTeams) and attaches pitchers to MLB GameCardData.
6. Freshness & caching
- Schedule cache TTL ≤ 30 min (
scheduleService). Frontend filters completed games older than 24h out of the slate (slateAdapter.isRelevantGame). - Player props: PropLine primary (3-key rotation), The-Odds-API backup.
grades:{sport}cache (TTL 2h) is written bygradeSlateServiceon a fresh odds fetch; it feeds/leaders+ the player card'sactiveProps.
7. Accuracy / self-learning loop (outcomeService, Session 55)
The system's track record — settled grades vs real results. Written by
outcomeService.settleAllOutcomes() (cron, before grading); read-only endpoints.
GET /api/accuracy (public, cached 5m)
{
overall: { // aggregate across sports
sport: 'overall', updated_at, window_days: 30, sample,
overall: { hits, misses, pushes, total, pct|null }, // pct excludes pushes
byGrade: { 'A+':{…}, 'A':{…}, 'B':{…}, 'C':{…}, 'D':{…}, 'F':{…} }
} | null,
sports: { mlb?: <record>, nba?: <record>, … }, // same shape per sport
min_sample: 8, // below this, show "LEARNING" not a %
updated_at: string | null
}
GET /api/ledger/accuracy (public) — the ledger buckets shape
{ buckets: [{ grade, hits, total, pct|null }], overall, updated_at }
Settled outcome overlay
GET /api/snapshot/:sport grades may carry outcome: { result:'hit'|'miss'| 'push', actual:number } once the game is final. AccuracyBadge +
StatStrip.OutcomeChip render these. Frontend proxy: web/src/app/api/accuracy.
S6 (A1 board) — display enrichments on the same payloads
GET /api/snapshot/:sportgrades may also carry:last10_dots: boolean[]— last ≤10 games vs the LOCKED line, newest first (server-side fromrosterlogs:{sport}viaservices/last10Dots). Absent when there's no real log / no accessor for the stat.history: [{ t: string, line: number }]— REAL captured line points from the intraday refresh (seeded with the locked line, deduped when flat, capped 24). The StatStrip sparkline renders only at ≥3 points.slateAdapter.buildPlayerStripsFromPropsthreads both ontoStripProp(last10Dots/history);StatStrip.DotStrip+LineSparklinerender.
GET /api/ledger/modelaggregatemay carryclv_distribution: [{ label, min, max, side:'faded'|'flat'|'beat', count }]— seven ordered buckets over settled signed CLV ([-2,-1) … (1,2], outliers clamped into the edge buckets). null below the n≥20 gate (the gate lives ingetModelAggregate, nowhere else). Ledger MODEL header renders the strip.- Global search:
window.__search()opens the ⌘K SearchModal (players via/api/players/searchper sport; teams from the staticweb/src/lib/teams.js).
8. Public ledger profiles (src/routes/profiles.js, A1 Session 10)
Strava-for-betting v1. Table public_profiles (migration 022 — apply
before deploy). PRIVATE BY DEFAULT; one explicit publish toggle in Settings.
GET /api/profiles/:handle (public, cached 60s)
Published only. Unknown AND unpublished return the SAME 404 body
({ error: 'Profile not found' }) — no existence leak.
{
handle: string,
aggregate: <getModelAggregate shape, scoped to the user via userId>,
entries: [<same columns as /api/ledger, SETTLED rows only, newest 50>],
min_sample: 20 // below this, RECORD BUILDING — never a %
}
HOUSE profile (Wave 5A, D2)
The reserved handle HOUSE_HANDLE (env, default vyndr) resolves to the
PUBLIC model record (getModelAggregate() with NO userId → the user_id=NULL
ledger rows) WITHOUT a public_profiles row, and is ALWAYS public (the
partner-pitch weapon). Same response shape + two extra fields:
{ ..., house: true, label: 'VYNDR MODEL · PUBLIC RECORD' }. Entries are the
public settled rows (user_id IS NULL, misses included). Reserved before the
publish lookup — a user who claims it is shadowed. Every OTHER handle keeps the
private-by-default, byte-identical-404 contract. It never 404s (a fetch failure
degrades to an honest empty/building state). Portrait share crop: route handler
GET /u/[handle]/portrait → 1080×1350 PNG (real aggregate or tagline fallback).
GET|POST /api/profiles/me (requireAuth)
GET → { profile: { handle, published, created_at } | null }.
POST { handle, published } → upsert own row (service role). Handle must
match ^[a-z0-9_]{3,20}$ (400); a handle owned by another user → 409.
published flips ONLY on boolean true.
Frontend: /u/[handle] (OPEN route, server shell + client record + OG card,
Node runtime) via proxies web/src/app/api/profiles/me|[handle].
Value Engine fields (Model Train arc 1, 2026-07-19)
Every graded read (snapshot grades:{sport}, /api/snapshot/:sport, /api/hero-prop,
/api/scan) now carries — all OPTIONAL + self-hiding when absent:
ev_pct(number) — expected value % at the graded side's ACTUAL price (model prob × decimal − 1). The ranking signal on boards + hero, replacing raw |model−consensus|.value(bool) — the read passes BOTH gates: takeable price AND ev_pct ≥ VALUE_EV_THRESHOLD. Grade = read quality;value= "the price pays you." An A withoutvalueis honest (right read, price gone).takeable(bool) — the graded-side price is inside the promotable band (TAKEABLE_ODDS_CEILING −160 .. +200).- The value triplet —
book_odds·fair_odds·model_odds(all American): the book's price, the de-vigged FAIR price (two-way multiplicative de-vig; present only when BOTH sides were priced — else absent, never faked), and the model's implied price. This is the story to render: "book −145 · vig-free −132 · model −110". Alsofair_prob,overround,devig_method. - Suppression reasons ride on refused reads:
suppressed+suppressed_reason(juiced_no_edge/rare_event_under) +reasoning.summary(branded "No read" copy) — for the visible-refusal moment (Model Train step 10).
DESIGN: the triplet + VALUE marker + refusal copy are Session-2 design surfaces (reveal card, board row, hero). Backend ships the fields; rendering is Design's.