diff --git a/CLAUDE.md b/CLAUDE.md index a827cfa..ce5439c 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,49 @@ # VYNDR โ€” Claude Code Project Context +--- + +# ๐Ÿ”ท PRODUCT IDENTITY โ€” READ FIRST, EVERY SESSION + +**VYNDR IS A PREDICTIVE MODEL.** It projects what a player will **DO**, and picks +accurately. It reads and pulls the market apart โ€” a student of the game that is +also an aggregator. + +**Market edge is a BYPRODUCT of a good prediction. It is NEVER the success +criterion.** + +> **SUCCESS = the forecast is honest about its own confidence AND still ranks.** +> Calibration (does 60% mean 60%?) *and* resolution (do higher forecasts actually +> hit more often?). Both, or it isn't working. + +**No edge or CLV term belongs in a pass/fail gate.** CLV and market-relative edge +are *diagnostics we report*, never thresholds a model must clear to ship. A model +that forecasts honestly and ranks correctly is working even in a week the market +moved against it; a model tuned to beat a closing line has been fitted to the +market instead of to the game. + +## PER-SPORT DOCTRINE +*(Rashad Phillips, "Basketball Position Metric," 2022 โ€” classify players by **what +they do**, not by position labels.)* + +**Each sport is its OWN model** โ€” its own variables, archetypes, conditions, +calibration and honest ceiling. The **only** thing shared across sports is the +**Bayesian inference math**. Never one model fit to all sports; never a sport +stubbed in on another sport's template and counted as covered. + +## TRUTH LAW +- **No fabricated data anywhere.** If it renders a number, it comes from the + database or it doesn't render. `Number(null) === 0` is the classic breach. +- **Honest-absent beats invented.** An empty state is a valid answer. +- **Label limitations in-band** โ€” e.g. "market consensus, **not sharp**", + "RECORD BUILDING", "MODEL ยท LEARNING". +- **Provisional results stay provisional until re-run.** A measurement taken + against an instrument that has since changed is not a result; it is a result + *pending*. +- **Verify by inducing the real code path on demand** โ€” never wait on a cron slot + to find out whether something works. Documented โ‰  verified. + +--- + ## What This Is Sports betting intelligence SaaS. Real software product. Three tiers: Free (5 scans), Analyst ($19.99 / $14.99 founder), Desk ($49.99 / $34.99 founder). diff --git a/specs/MASTER-PLAN.md b/specs/MASTER-PLAN.md index 5d2cbcf..05e2ea7 100644 --- a/specs/MASTER-PLAN.md +++ b/specs/MASTER-PLAN.md @@ -1,6 +1,64 @@ # VYNDR โ€” MASTER PLAN **Single source of truth. Sessions EXECUTE against this and UPDATE it in place.** -Created 2026-07-31 by consolidation. Nothing built in this pass. +Created 2026-07-31 by consolidation. Last updated 2026-08-01. + +--- + +## ๐Ÿ”ท PRODUCT IDENTITY โ€” the thing being built + +**VYNDR IS A PREDICTIVE MODEL.** It projects what a player will **DO** and picks +accurately โ€” reading and pulling the market apart. Student of the game, and an +aggregator. + +**Market edge is a BYPRODUCT of a good prediction, NEVER the success criterion.** + +> **SUCCESS = the forecast is honest about its own confidence AND still ranks.** +> Calibration *and* resolution. **No edge/CLV term belongs in a pass/fail gate** โ€” +> they are diagnostics we report, not thresholds a model must clear to ship. + +**PER-SPORT DOCTRINE** (Rashad Phillips, *Basketball Position Metric*, 2022 โ€” +classify players by **what they do**, not position labels): each sport is its OWN +model, with its own variables, archetypes, conditions, calibration and honest +ceiling. Shared across sports: **only the Bayesian inference math.** + +**TRUTH LAW:** no fabricated data anywhere ยท honest-absent over invented ยท label +limitations in-band ("market consensus, **not sharp**") ยท **provisional results +stay provisional until re-run** ยท documented โ‰  verified. + +--- + +## โ–ถ NEXT EXECUTABLE ORDER + +**The MLB calibration RE-RUN against the consensus ruler.** Everything +model-shaped is downstream of it: + +- MLB isotonic `p_win` cannot promote until re-run (it was calibrated against + `v1_first_book`). +- The newly-visible props cannot feed the model until it promotes. +- Every edge/CLV number resets to `ruler_version = v2_consensus` at that boundary + and **must not be pooled** with what came before. + +*Blocked on nothing. This is the next build.* + +--- + +## ๐Ÿ“ STATE AS OF 2026-08-01 (Order Zero, measured on prod with the real key) + +| finding | number | what it means | +|---|---|---| +| **MLB slate invisible to us** | **64.8%** | our own allow-list, not the feed โ€” now widened for DISPLAY | +| **books/prop, MLB** | 3.61 feed โ†’ 0.57 after filter | the filter cost, quantified | +| **books/prop, WNBA** | **4.21** feed โ†’ 1.20 | **WNBA is BETTER covered than MLB** | +| **consensus ruler** | **MARKET, not SHARP** | `pinnacle`/`matchbook`/`polymarket` = **0%** on both sports. No sharp anchor exists in our feed. Permanent limitation, not a milestone | +| **ruler delta** (consensus โˆ’ incumbent) | MLB mean +1.50 pts, median 0, **17% of props move โ‰ฅ5 pts** | rulers genuinely differ; "better" is unproven | +| **MLB isotonic `p_win`** | **PROVISIONAL** | calibrated on the bent ruler; does not promote until re-run | +| **WNBA** | **still abstains** | a MODEL problem, not a coverage problem โ€” coverage was never its constraint | +| **soccer** | **settles** โ€” ~15 competitions, 30d | "grades into a void" is a **$19/mo Pro-tier** problem, not a data problem | +| **CLV + results feeds** | `/odds/closing` + `/movement` **redacted**; `/results` + `/exports/resolved-props` **403** | free tier behaving exactly as its contract says | +| **accrual clock** | **sequential, post-completion** | see ยง11. Pre-completion data does not count and is never pooled | + +**The honest framing:** widening books is an **AGGREGATOR** win. **It does not fix +the model.** Do not let the free-side win read as model progress. > **HOW TO USE:** this supersedes ad-hoc re-derivation. Before any order, read the > phase you're in. After any order, tick the item and add one line. **Do not diff --git a/src/config/bookRoles.js b/src/config/bookRoles.js index 9999c27..720fdd6 100644 --- a/src/config/bookRoles.js +++ b/src/config/bookRoles.js @@ -102,14 +102,59 @@ const MIN_CONSENSUS_BOOKS = 2; const roleOf = (book) => { const b = String(book || '').toLowerCase(); - if (EXCLUDED_FROM_PRICING.has(b)) return 'excluded'; + if (DFS_PLATFORMS.has(b)) return 'dfs'; + if (OFFSHORE_OR_INTL.has(b)) return 'offshore'; if (REFERENCE_BOOKS.has(b) && TAKEABLE_BOOKS.has(b)) return 'both'; if (REFERENCE_BOOKS.has(b)) return 'reference'; if (TAKEABLE_BOOKS.has(b)) return 'takeable'; return 'unknown'; }; +/** + * MODEL_BOOKS โ€” the ONLY books whose prices may reach a grade. + * + * This is the legacy `oddsNormalizer.ALLOWED_BOOKS`, character for character, + * ON PURPOSE. Widening what the SURFACES show is an aggregator change; widening + * what the MODEL eats is a model change, and the model has never been measured + * against those books. Until the MLB calibration is re-run on the consensus + * ruler, the model's input stays byte-identical. Two different questions, two + * different sets โ€” that separation is the whole point of this file. + */ +const MODEL_BOOKS = Object.freeze(new Set([ + 'draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', + 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle', 'thescore', +])); + +/** + * DISPLAY_BOOKS โ€” everything the surfaces may SHOW. + * + * Line shopping across many books is the category's #1 free-tier hook and the + * acquisition surface we have never had. PropLine already sends these; we were + * throwing 13 of 18 away, which made 64.8% of the MLB slate invisible. + * + * Every prop carries a `book_role` so the display layer can be honest about + * WHAT each price is: a DFS pick'em number and a two-way sportsbook price are + * not the same object and must never be rendered as interchangeable. + */ +const DISPLAY_BOOKS = Object.freeze(new Set([ + ...MODEL_BOOKS, + ...REFERENCE_BOOKS, + ...DFS_PLATFORMS, + ...OFFSHORE_OR_INTL, +])); + +/** May this book's price reach a grade? (Not "may we show it" โ€” see below.) */ +const isModelBook = (book) => MODEL_BOOKS.has(String(book || '').toLowerCase()); +/** May we render this book at all? */ +const isDisplayBook = (book) => DISPLAY_BOOKS.has(String(book || '').toLowerCase()); +/** May this book price the fair-probability ruler? DFS/offshore: never. */ +const isReferenceBook = (book) => REFERENCE_BOOKS.has(String(book || '').toLowerCase()); +/** Can a user actually place this bet? Drives best-price + deep links. */ +const isTakeableBook = (book) => TAKEABLE_BOOKS.has(String(book || '').toLowerCase()); + module.exports = { + MODEL_BOOKS, DISPLAY_BOOKS, + isModelBook, isDisplayBook, isReferenceBook, isTakeableBook, TAKEABLE_BOOKS, REFERENCE_BOOKS, EXCLUDED_FROM_PRICING, DFS_PLATFORMS, OFFSHORE_OR_INTL, PHANTOM_ON_PROPLINE, EXCHANGES, SHARP, US_MAJORS, diff --git a/src/services/gradeSlateService.js b/src/services/gradeSlateService.js index 3fff476..05d91b2 100644 --- a/src/services/gradeSlateService.js +++ b/src/services/gradeSlateService.js @@ -26,16 +26,28 @@ // We dedupe to unique player+stat+line and cap how many we grade, because // each grade fans out to feature computation. Grading runs at most once per // cache-miss per sport, but we still bound the herd. +const { isModelBook } = require('../config/bookRoles'); + const DEFAULT_LIMIT = 25; const DEFAULT_CONCURRENCY = 5; const DEFAULT_TTL = 7200; // 2 hours โ€” matches the spec's grades-cache TTL. // Collapse the multi-book prop rows to one entry per gradeable prop. +// +// ORDER ZERO GATE (2026-08-01). `normalizeProps` now emits every DISPLAY book so +// the surfaces can shop lines, which means DFS pick'em and exchange rows arrive +// here for the first time. The MODEL must not eat them: it has never been +// measured against those books, and a fixed-payout DFS number is not a market +// price at all. So we re-filter to MODEL_BOOKS BEFORE the first-row-wins pick +// and before the limit โ€” which makes the graded set byte-identical to what it +// was before the widening. This gate lifts only when the MLB calibration is +// re-run on the consensus ruler and v2 is promoted. function dedupeProps(props, limit) { const seen = new Set(); const out = []; for (const p of props || []) { if (!p || !p.player || !p.stat_type || p.line == null) continue; + if (!isModelBook(p.book)) continue; const key = `${p.player}::${p.stat_type}::${p.line}`; if (seen.has(key)) continue; seen.add(key); diff --git a/src/services/intradayRefreshService.js b/src/services/intradayRefreshService.js index 20d2786..0239e4a 100644 --- a/src/services/intradayRefreshService.js +++ b/src/services/intradayRefreshService.js @@ -36,6 +36,8 @@ const STEAM_NOISE = 0.5; // ignore movement below this (both directions) const REGRADE_TRIGGER = 1.0; // moved-against threshold that triggers a re-grade const SNAP_TTL = 24 * 3600; // keep in sync with snapshotService const TICKER_MOVE_CAP = 6; +const { isModelBook } = require('../config/bookRoles'); + const HISTORY_CAP = 24; // {t, line} points per grade (S6 sparklines) 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 }; @@ -43,10 +45,16 @@ const rank = (g) => (g && GRADE_RANK[g] !== undefined ? GRADE_RANK[g] : 99); const sideOver = (dir) => String(dir || 'over').toLowerCase() !== 'under'; +// ORDER ZERO GATE โ€” same reasoning as gradeSlateService.dedupeProps. This index +// feeds line-movement detection and RE-GRADES props at the current line, so it +// is a MODEL path: it must see only MODEL_BOOKS. Without this gate the widened +// display feed would silently move locked lines onto DFS/exchange numbers the +// model has never been calibrated against. function indexOddsProps(props) { const map = {}; for (const p of props || []) { if (!p || !p.player || !p.stat_type) continue; + if (!isModelBook(p.book)) continue; const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`; if (!map[k]) map[k] = p; } diff --git a/src/services/proplineVerify.js b/src/services/proplineVerify.js index 2dbf818..d9b9f3a 100644 --- a/src/services/proplineVerify.js +++ b/src/services/proplineVerify.js @@ -382,6 +382,12 @@ async function probe(path, params, note, httpGet) { ...(rows != null ? { csv_rows: rows } : {}), ...(Object.keys(headers).length ? { headers } : {}), shape: scrubKeys(summarise(body)).slice(0, 700), + // The REASON, verbatim. A bare 403 does not distinguish "free tier + // excludes this" from "this key lacks a scope" from "your plan lapsed", + // and the difference is a $19/mo decision. PropLine puts it in `detail`. + ...(status >= 400 && body && typeof body === 'object' + ? { detail: scrubKeys(JSON.stringify(body.detail != null ? body.detail : body)).slice(0, 600) } + : {}), }; } catch (err) { return { path, verdict: 'no', note, error: scrubKeys(err && err.message) }; diff --git a/src/utils/oddsNormalizer.js b/src/utils/oddsNormalizer.js index d65c46a..68034ce 100644 --- a/src/utils/oddsNormalizer.js +++ b/src/utils/oddsNormalizer.js @@ -6,7 +6,24 @@ const { getAbbreviation } = require('./teamMap'); // left OUT โ€” it's the canonical "not-allowed" example in the tests, and // VYNDR surfaces regulated US books.) // 'thescore' = theScore Bet (PENN), successor to the defunct ESPN BET (item 7). -const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle', 'thescore']); +// +// ORDER ZERO (2026-08-01) โ€” the allow-list is now TWO sets, because it was +// answering two different questions with one answer. +// +// DISPLAY_BOOKS โ€” what the SURFACES may show. Widened to everything the feed +// already sends. Before this, we discarded 13 of PropLine's +// 18 books and 64.8% of the MLB slate was invisible to users. +// Line shopping across books is the category's #1 free hook. +// MODEL_BOOKS โ€” what may reach a GRADE. Byte-identical to the old list on +// purpose: the model has never been measured against the new +// books, and it must not start eating them until the MLB +// calibration is re-run on the consensus ruler. +// +// `ALLOWED_BOOKS` is kept as an alias for MODEL_BOOKS so existing model-side +// callers (combatAdapter, consensusRuler's incumbent) keep their old meaning. +// Every emitted prop now carries `book_role` so no consumer has to re-derive it. +const { DISPLAY_BOOKS, MODEL_BOOKS, roleOf } = require('../config/bookRoles'); +const ALLOWED_BOOKS = MODEL_BOOKS; const MARKET_MAP = { // NBA / WNBA props @@ -107,7 +124,9 @@ function normalizeProps(eventsWithOdds) { if (!Array.isArray(event.bookmakers)) continue; for (const bookmaker of event.bookmakers) { - if (!ALLOWED_BOOKS.has(bookmaker.key)) continue; + // DISPLAY gate โ€” the model re-filters to MODEL_BOOKS downstream + // (gradeSlateService.dedupeProps, intradayRefreshService.indexOddsProps). + if (!DISPLAY_BOOKS.has(bookmaker.key)) continue; if (!Array.isArray(bookmaker.markets)) continue; @@ -142,6 +161,11 @@ function normalizeProps(eventsWithOdds) { player: entry.player, home_team: homeTeam, away_team: awayTeam, + // What KIND of price this is. 'dfs' = fixed-payout pick'em, which is + // NOT a market price and must never enter a consensus or a fair + // value โ€” it is shown for breadth and tagged so nothing downstream + // can mistake it for a book. + book_role: roleOf(bookmaker.key), game_time: gameTime, stat_type: statType, book: bookmaker.key, diff --git a/tests/unit/consensusRuler.test.js b/tests/unit/consensusRuler.test.js index 3990a0f..1101701 100644 --- a/tests/unit/consensusRuler.test.js +++ b/tests/unit/consensusRuler.test.js @@ -19,7 +19,13 @@ describe('bookRoles โ€” the three-way split', () => { for (const dfs of roles.DFS_PLATFORMS) { expect(roles.REFERENCE_BOOKS.has(dfs)).toBe(false); expect(roles.EXCLUDED_FROM_PRICING.has(dfs)).toBe(true); - expect(roles.roleOf(dfs)).toBe('excluded'); + // roleOf now names the KIND ('dfs'), which is stronger than 'excluded': + // the display layer needs to say WHAT a price is, not just that it can't + // price. Exclusion from pricing is asserted on the line above. + expect(roles.roleOf(dfs)).toBe('dfs'); + expect(roles.isReferenceBook(dfs)).toBe(false); + expect(roles.isModelBook(dfs)).toBe(false); + expect(roles.isDisplayBook(dfs)).toBe(true); // shown for breadth, tagged } }); @@ -144,3 +150,32 @@ describe('consensusRuler โ€” the incumbent it is challenging', () => { expect(compareRulers([q('prizepicks', 1.5, -119, -119)], 1.5, 'over', { allowedBooks: LIVE_ALLOWED }).delta_pts).toBeNull(); }); }); + +describe('bookRoles โ€” display vs model separation (Order Zero Phase 2/3)', () => { + it('MODEL_BOOKS is the legacy allow-list, unchanged โ€” the model must not move', () => { + expect([...roles.MODEL_BOOKS].sort()).toEqual([ + 'bet365', 'betmgm', 'betrivers', 'caesars', 'draftkings', 'fanatics', + 'fanduel', 'hardrockbet', 'pinnacle', 'pointsbet', 'thescore', + ].sort()); + }); + + it('DISPLAY_BOOKS is a strict superset of MODEL_BOOKS', () => { + for (const b of roles.MODEL_BOOKS) expect(roles.DISPLAY_BOOKS.has(b)).toBe(true); + expect(roles.DISPLAY_BOOKS.size).toBeGreaterThan(roles.MODEL_BOOKS.size); + }); + + it('NO DFS or offshore book may reach the model, however visible it is', () => { + for (const b of [...roles.DFS_PLATFORMS, ...roles.OFFSHORE_OR_INTL]) { + expect(roles.isDisplayBook(b)).toBe(true); + expect(roles.isModelBook(b)).toBe(false); + expect(roles.isReferenceBook(b)).toBe(false); + } + }); + + it('an exchange is visible and may rule, but is never TAKEABLE', () => { + for (const b of roles.EXCHANGES) { + expect(roles.isDisplayBook(b)).toBe(true); + expect(roles.isTakeableBook(b)).toBe(false); + } + }); +}); diff --git a/tests/unit/oddsNormalizer.test.js b/tests/unit/oddsNormalizer.test.js index 7c3d6ff..34d23b5 100644 --- a/tests/unit/oddsNormalizer.test.js +++ b/tests/unit/oddsNormalizer.test.js @@ -66,27 +66,55 @@ describe('oddsNormalizer', () => { }); }); - it('filters out books not in the allowed set', () => { + // SUPERSEDED 2026-08-01 (Order Zero). This used to assert bovada was + // DROPPED. It is now emitted for DISPLAY โ€” we were discarding 13 of the + // feed's 18 books, which made 64.8% of the MLB slate invisible. The + // property that replaces it is strictly stronger: the display set widens, + // every row is TAGGED with what kind of price it is, an unknown book is + // still dropped, and the MODEL set does not move. + it('emits every DISPLAY book, tagged with its role, and still drops unknown books', () => { + const { MODEL_BOOKS } = require('../../src/config/bookRoles'); const event = makeEvent({ - bookmakers: [ - makeBookmaker('bovada', [ + bookmakers: ['bovada', 'draftkings', 'prizepicks', 'novig', 'not_a_real_book'].map((k) => + makeBookmaker(k, [ makeMarket('player_points', [ makeOutcome('Over', 'Jokic', -110, 26.5), makeOutcome('Under', 'Jokic', -110, 26.5), ]), - ]), - makeBookmaker('draftkings', [ - makeMarket('player_points', [ - makeOutcome('Over', 'Jokic', -110, 26.5), - makeOutcome('Under', 'Jokic', -110, 26.5), - ]), - ]), - ], + ])), }); const result = normalizeProps([event]); - expect(result).toHaveLength(1); - expect(result[0].book).toBe('draftkings'); + const byBook = Object.fromEntries(result.map((r) => [r.book, r.book_role])); + + expect(byBook).toEqual({ + draftkings: 'both', + bovada: 'reference', + novig: 'reference', + prizepicks: 'dfs', // shown for breadth, tagged, never a market price + }); + expect(byBook.not_a_real_book).toBeUndefined(); // unknown books still dropped + expect(ALLOWED_BOOKS).toBe(MODEL_BOOKS); // the alias still means MODEL + }); + + it('MODEL INPUT IS BYTE-IDENTICAL despite the display widening', () => { + // The grader re-filters to MODEL_BOOKS before first-row-wins, so widening + // what the surfaces show cannot change a single graded prop. This is the + // gate that lifts only when the consensus ruler is promoted. + const { dedupeProps } = require('../../src/services/gradeSlateService').__internals; + const event = makeEvent({ + bookmakers: ['prizepicks', 'novig', 'bovada', 'draftkings'].map((k) => + makeBookmaker(k, [ + makeMarket('player_points', [ + makeOutcome('Over', 'Jokic', -110, 26.5), + makeOutcome('Under', 'Jokic', -110, 26.5), + ]), + ])), + }); + + const graded = dedupeProps(normalizeProps([event]), 25); + expect(graded).toHaveLength(1); + expect(graded[0].book).toBe('draftkings'); // exactly what it was before }); it('maps every market key to its internal stat_type (NBA + soccer)', () => {