7a925f43eb
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>
299 lines
14 KiB
Markdown
299 lines
14 KiB
Markdown
# 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 `legacyName` and 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); `key` is accent-folded, lowercased, suffix-stripped for comparison.
|
||
Used by snapshot grouping, `slateAdapter` (grade index + player strips), and
|
||
`playerIntelService` so "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 by `gradeSlateService` on a fresh
|
||
odds fetch; it feeds `/leaders` + the player card's `activeProps`.
|
||
|
||
---
|
||
|
||
## 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/:sport` grades may also carry:
|
||
- `last10_dots: boolean[]` — last ≤10 games vs the LOCKED line, newest
|
||
first (server-side from `rosterlogs:{sport}` via `services/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.buildPlayerStripsFromProps` threads both onto `StripProp`
|
||
(`last10Dots` / `history`); `StatStrip.DotStrip` + `LineSparkline` render.
|
||
- `GET /api/ledger/model` `aggregate` may carry `clv_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 in
|
||
`getModelAggregate`, nowhere else). Ledger MODEL header renders the strip.
|
||
- Global search: `window.__search()` opens the ⌘K SearchModal (players via
|
||
`/api/players/search` per sport; teams from the static `web/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
|
||
without `value` is 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". Also `fair_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.
|