'use strict'; /** * proplineVerify — Order Zero Phase 1: KEYED, READ-ONLY verification. * * Runs where the PropLine key lives (prod). Answers four questions the public * endpoints could not: * * 1. WNBA real book breadth — is WNBA thin AT THE FEED, or allow-list-starved * the way MLB was? (Per sport. Never pooled.) * 2. Exchange reality — do novig / smarkets / kalshi / matchbook / polymarket * actually carry props on a live slate? The exchange-as-ruler is a * HYPOTHESIS; this confirms it or forces the soft-book fallback. * 3. Endpoint verification — /results, /exports/resolved-props, /odds/closing, * /movement, /context: works / partial / no, FOR OUR TIER. Documented is * not verified. * 4. Sport coverage — /v1/sports, which rewrites sport-rollout economics. * * READ-ONLY. Touches no cache, no ledger, no grade. The live adapter is * untouched — this reuses `proplineAdapter.fetchRaw` (the exact live request) * for breadth, and issues its own direct probes for the unused endpoints. * * KEY SAFETY: the API key is passed via axios `params` and never interpolated * into a logged string. Every value this module returns — including error * messages and any echoed URL — goes through `scrubKeys()`, which replaces the * literal key with ''. Belt and braces: an axios error carries * `config.url` and some servers echo the query string back. */ const axios = require('axios'); const proplineAdapter = require('./adapters/proplineAdapter'); const { __internals: PA } = proplineAdapter; const BASE = 'https://api.prop-line.com/v1'; const TIMEOUT_MS = 15_000; // The three roles. Only REFERENCE books may ever price the ruler. // DFS pick'em is PERMANENTLY EXCLUDED: fixed-payout shaded lines are not a // market price, whatever their coverage. const REFERENCE_CANDIDATES = ['pinnacle', 'novig', 'smarkets', 'matchbook', 'kalshi', 'polymarket', 'bovada']; const DFS_PLATFORMS = ['prizepicks', 'underdog', 'sleeper', 'dabble']; /** Replace every configured key with '' anywhere in a string. */ function scrubKeys(str) { let s = String(str == null ? '' : str); for (const k of PA.getKeys()) { if (k && k.length > 3) s = s.split(k).join(''); } // Defence in depth: kill any surviving apiKey=… query value. return s.replace(/(apiKey=)[^&\s]+/gi, '$1'); } const median = (xs) => { if (!xs.length) return null; const s = [...xs].sort((a, b) => a - b); const m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; }; const round2 = (n) => (Number.isFinite(n) ? Math.round(n * 100) / 100 : null); /** * Book breadth for one sport, computed from the RAW feed (pre-filter) and again * AFTER `oddsNormalizer.ALLOWED_BOOKS` — the delta is what our own config costs. * * A "prop" is (event, player, market). `invisible` = props with ZERO allowed * books: they do not exist to VYNDR at all. */ function analyseBreadth(raw, allowedBooks) { // A PROP is (event, player, market). A PRICED QUOTE is that prop at one // book's line — and only a TWO-SIDED quote can be de-vigged, so the ruler's // real eligibility question is "how many reference books post BOTH sides at // the SAME line", not "how many books touched this player". const byProp = new Map(); // propKey -> Set(book) const byPropLine = new Map(); // propKey|line -> Set(book) (two-sided only) const bookRows = new Map(); let events = 0; for (const ev of raw || []) { if (!ev || !Array.isArray(ev.bookmakers)) continue; events += 1; for (const bm of ev.bookmakers) { if (!bm || !bm.key || !Array.isArray(bm.markets)) continue; for (const mk of bm.markets) { const outcomes = (mk && mk.outcomes) || []; // Pair Over/Under exactly as oddsNormalizer does. const pairs = new Map(); for (const oc of outcomes) { if (!oc || !oc.description || oc.point == null) continue; const k = `${oc.description}::${oc.point}`; if (!pairs.has(k)) pairs.set(k, { player: oc.description, point: oc.point }); if (oc.name === 'Over') pairs.get(k).over = oc.price; else if (oc.name === 'Under') pairs.get(k).under = oc.price; } for (const q of pairs.values()) { const propKey = `${ev.id}|${q.player}|${mk.key}`; if (!byProp.has(propKey)) byProp.set(propKey, new Set()); byProp.get(propKey).add(bm.key); bookRows.set(bm.key, (bookRows.get(bm.key) || 0) + 1); if (q.over != null && q.under != null) { const lk = `${propKey}|${q.point}`; if (!byPropLine.has(lk)) byPropLine.set(lk, new Set()); byPropLine.get(lk).add(bm.key); } } } } } const feedCounts = []; const filteredCounts = []; let invisible = 0; const refPresent = {}; const dfsPresent = {}; for (const b of REFERENCE_CANDIDATES) refPresent[b] = 0; for (const b of DFS_PLATFORMS) dfsPresent[b] = 0; for (const books of byProp.values()) { feedCounts.push(books.size); let kept = 0; for (const b of books) if (allowedBooks.has(b)) kept += 1; filteredCounts.push(kept); if (kept === 0) invisible += 1; for (const b of REFERENCE_CANDIDATES) if (books.has(b)) refPresent[b] += 1; for (const b of DFS_PLATFORMS) if (books.has(b)) dfsPresent[b] += 1; } // Ruler eligibility, the strict version: >= 2 REFERENCE books posting BOTH // sides at the SAME line. Anything less is a labelled single-book fallback. let eligible2 = 0; let eligible3 = 0; let twoSidedGroups = 0; const bestRefPerProp = new Map(); for (const [lk, books] of byPropLine.entries()) { twoSidedGroups += 1; let r = 0; for (const b of REFERENCE_CANDIDATES) if (books.has(b)) r += 1; const propKey = lk.slice(0, lk.lastIndexOf('|')); bestRefPerProp.set(propKey, Math.max(bestRefPerProp.get(propKey) || 0, r)); } for (const r of bestRefPerProp.values()) { if (r >= 2) eligible2 += 1; if (r >= 3) eligible3 += 1; } // Same strict rule (two-sided, same line) under each candidate policy. const policyCurve = {}; for (const [name, set] of Object.entries(REFERENCE_POLICIES)) { const best = new Map(); for (const [lk, books] of byPropLine.entries()) { let r = 0; for (const b of set) if (books.has(b)) r += 1; const pk = lk.slice(0, lk.lastIndexOf('|')); best.set(pk, Math.max(best.get(pk) || 0, r)); } let n2 = 0; let n3 = 0; for (const r of best.values()) { if (r >= 2) n2 += 1; if (r >= 3) n3 += 1; } policyCurve[name] = { n2, n3 }; } const props = feedCounts.length; const hist = (xs) => { const h = {}; for (const n of xs) h[n] = (h[n] || 0) + 1; return h; }; const mean = (xs) => (xs.length ? round2(xs.reduce((a, b) => a + b, 0) / xs.length) : null); const atMostOne = (xs) => (xs.length ? round2((100 * xs.filter((n) => n <= 1).length) / xs.length) : null); const pctOf = (n) => (props ? round2((100 * n) / props) : null); return { events, props, two_sided_prop_line_groups: twoSidedGroups, books_in_feed: [...bookRows.entries()].sort((a, b) => b[1] - a[1]) .map(([book, quotes]) => ({ book, quotes, admitted: allowedBooks.has(book) })), feed: { mean_books_per_prop: mean(feedCounts), median_books_per_prop: median(feedCounts), at_most_one_book_pct: atMostOne(feedCounts), histogram: hist(feedCounts), }, after_allow_list: { mean_books_per_prop: mean(filteredCounts), median_books_per_prop: median(filteredCounts), at_most_one_book_pct: atMostOne(filteredCounts), histogram: hist(filteredCounts), }, invisible_props: invisible, invisible_pct: pctOf(invisible), reference_presence: Object.fromEntries( REFERENCE_CANDIDATES.map((b) => [b, { props: refPresent[b], pct: pctOf(refPresent[b]) }]), ), dfs_presence_EXCLUDED_FROM_CONSENSUS: Object.fromEntries( DFS_PLATFORMS.map((b) => [b, { props: dfsPresent[b], pct: pctOf(dfsPresent[b]) }]), ), // THE decision number: how much of the slate can carry a real consensus. consensus_eligible_n2: eligible2, consensus_eligible_n2_pct: pctOf(eligible2), consensus_eligible_n3: eligible3, consensus_eligible_n3_pct: pctOf(eligible3), reference_policy_curve: Object.fromEntries(Object.entries(policyCurve).map(([k, v]) => [k, { books: REFERENCE_POLICIES[k], n2: v.n2, n2_pct: pctOf(v.n2), n3: v.n3, n3_pct: pctOf(v.n3), }])), }; } /** * REDACTION DETECTION. PropLine's free tier returns the full STRUCTURE of * tier-gated endpoints with the values stripped, plus an `upgrade_url`. A * non-empty body is therefore NOT proof of access — the first pass classified * /odds/closing as "works" on structure alone. This counts actual prices. * * Returns { outcomes, priced, redacted } — `redacted: true` when the body * advertises an upgrade or carries outcomes with no prices at all. */ function detectRedaction(body) { if (!body || typeof body !== 'object') return null; let outcomes = 0; let priced = 0; const walkBooks = (books) => { for (const bm of books || []) { for (const mk of (bm && bm.markets) || []) { for (const oc of (mk && mk.outcomes) || []) { outcomes += 1; if (oc && oc.price != null) priced += 1; } } } }; walkBooks(body.bookmakers); const advertised = body.upgrade_url != null || body.redacted === true || (typeof body.redacted === 'string' && body.redacted.length > 0); return { outcomes, priced, steam_count: Array.isArray(body.steam) ? body.steam.length : null, advertises_upgrade: !!advertised, redacted: advertised || (outcomes > 0 && priced === 0), }; } /** * Consensus coverage under several candidate REFERENCE-SET policies, so the * ruler decision is made on a coverage/quality curve rather than on one * hard-coded set. DFS is absent from every policy by construction. */ const REFERENCE_POLICIES = Object.freeze({ exchange_only: ['novig', 'smarkets', 'kalshi', 'matchbook', 'polymarket'], exchange_plus_sharp:['novig', 'smarkets', 'kalshi', 'matchbook', 'polymarket', 'pinnacle', 'bovada'], exchange_plus_us: ['novig', 'smarkets', 'kalshi', 'matchbook', 'polymarket', 'pinnacle', 'bovada', 'draftkings', 'betmgm', 'betrivers', 'fanduel'], takeable_only: ['draftkings', 'fanduel', 'betmgm', 'betrivers'], }); /** One read-only probe. Never throws; classifies works / partial / no. */ async function probe(path, params, note, httpGet) { const get = httpGet || axios.get; const keys = PA.getKeys().filter(Boolean); if (!keys.length) return { path, verdict: 'no', reason: 'no key configured', note }; try { const res = await get(`${BASE}${path}`, { params: { apiKey: keys[0], ...params }, timeout: TIMEOUT_MS, validateStatus: () => true, // CSV endpoints stream; cap what we read. maxContentLength: 2_000_000, }); const status = res.status; const body = res.data; const isCsv = typeof body === 'string'; const rows = isCsv ? Math.max(0, body.split('\n').filter(Boolean).length - 1) : null; const headers = {}; for (const h of ['x-propline-archive-starts', 'x-propline-archive-notice', 'x-propline-export-window-start']) { if (res.headers && res.headers[h]) headers[h] = String(res.headers[h]); } let verdict = 'no'; if (status >= 200 && status < 300) { const empty = isCsv ? rows === 0 : (body == null || (Array.isArray(body) && !body.length) || (typeof body === 'object' && !Object.keys(body).length)); verdict = empty ? 'partial' : 'works'; } else if (status === 402 || status === 403) { verdict = 'no'; // tier-gated for us } else if (status === 404) { verdict = 'partial'; // reachable, nothing on file for this target } const redaction = detectRedaction(body); if (verdict === 'works' && redaction && redaction.redacted) verdict = 'partial'; return { path, verdict, status, note, ...(redaction ? { redaction } : {}), ...(rows != null ? { csv_rows: rows } : {}), ...(Object.keys(headers).length ? { headers } : {}), shape: scrubKeys(summarise(body)).slice(0, 700), }; } catch (err) { return { path, verdict: 'no', note, error: scrubKeys(err && err.message) }; } } /** A short, key-free description of a response body. */ function summarise(body) { if (body == null) return 'null'; if (typeof body === 'string') return `csv/text: ${body.split('\n').slice(0, 2).join(' | ')}`; if (Array.isArray(body)) { return `array(${body.length})` + (body.length ? ` first_keys=[${Object.keys(body[0] || {}).join(',')}]` : ''); } if (typeof body === 'object') { const out = [`object keys=[${Object.keys(body).join(',')}]`]; for (const [k, v] of Object.entries(body)) { if (Array.isArray(v)) out.push(`${k}: array(${v.length})${v.length && typeof v[0] === 'object' ? ` keys=[${Object.keys(v[0]).join(',')}]` : ''}`); } return out.join(' ; '); } return String(body); } /** * The full Phase-1 report. `deps` injectable so tests never touch the network. */ async function verify(opts = {}) { const sports = opts.sports && opts.sports.length ? opts.sports : ['mlb', 'wnba']; const fetchRaw = opts.fetchRaw || proplineAdapter.fetchRaw; const httpGet = opts.httpGet; const allowed = opts.allowedBooks || require('../utils/oddsNormalizer').ALLOWED_BOOKS || new Set(); const out = { generated_at: new Date().toISOString(), read_only: true, live_adapter_untouched: true, allow_list_size: allowed.size, per_sport: {}, endpoints: [], notes: [], }; if (!proplineAdapter.hasKeys()) { out.notes.push('NO PROPLINE KEY IN THIS ENV — nothing was measured. Run where the key lives.'); return out; } // 1 + 2 — breadth and exchange reality, PER SPORT. Never pooled. const firstEvent = {}; for (const sport of sports) { try { const raw = await fetchRaw(sport); if (!Array.isArray(raw)) { out.per_sport[sport] = { error: 'no response / unsupported sport' }; continue; } out.per_sport[sport] = analyseBreadth(raw, allowed); const ev = raw.find((e) => e && e.id && Array.isArray(e.bookmakers) && e.bookmakers.length); if (ev) firstEvent[sport] = { id: ev.id, key: PA.SPORT_KEYS[sport] }; } catch (err) { out.per_sport[sport] = { error: scrubKeys(err && err.message) }; } } // 4 — sport coverage + graded VOLUME per sport. `/exports/resolved-props` // being 403 tells us we can't PULL settlements; resolution-summary tells us // whether they EXIST to be bought. Two different questions. out.endpoints.push(await probe('/sports', {}, 'sport coverage — which sports PropLine carries', httpGet)); out.endpoints.push(await probe('/markets/resolution-summary', { days: 30 }, 'does PropLine actually GRADE nba/soccer? (the $19/mo question)', httpGet)); // 3 — the four documented-but-unverified endpoints, against a REAL event. for (const sport of sports) { const ev = firstEvent[sport]; if (!ev) { out.notes.push(`${sport}: no event with bookmakers on the live slate — endpoint probes skipped for this sport.`); continue; } const markets = (PA.MARKETS[sport] || []).slice(0, 3).join(','); const p = `/sports/${ev.key}/events/${ev.id}`; out.endpoints.push(await probe(`${p}/context`, {}, `${sport} — free? probable pitchers / lineups / umpire / weather`, httpGet)); out.endpoints.push(await probe(`${p}/odds/closing`, { markets }, `${sport} — canonical CLV helper`, httpGet)); out.endpoints.push(await probe(`${p}/movement`, { markets }, `${sport} — steam across books`, httpGet)); out.endpoints.push(await probe(`${p}/results`, { markets }, `${sport} — settled outcomes + actual values`, httpGet)); } // AGGREGATE-ONLY bodies, returned in full. These two carry no per-prop data // and no credentials — and the shape summary alone can't answer the question // they exist to answer: does PropLine actually GRADE the sports we can't // settle? A shape is not a number. out.aggregates = {}; for (const [name, path, params] of [ ['sports', '/sports', {}], ['resolution_summary', '/markets/resolution-summary', { days: 30 }], ]) { try { const keys = PA.getKeys().filter(Boolean); const res = await (httpGet || axios.get)(`${BASE}${path}`, { params: { apiKey: keys[0], ...params }, timeout: TIMEOUT_MS, validateStatus: () => true, }); out.aggregates[name] = res.status === 200 ? JSON.parse(scrubKeys(JSON.stringify(res.data))) : { error: `HTTP ${res.status}` }; } catch (err) { out.aggregates[name] = { error: scrubKeys(err && err.message) }; } } // The $19/mo question: does the bulk resolved export settle sports we can't? for (const sportKey of ['baseball_mlb', 'basketball_wnba', 'basketball_nba', 'soccer_epl']) { out.endpoints.push(await probe('/exports/resolved-props', { sport: sportKey }, `bulk resolved export — ${sportKey}`, httpGet)); } return out; } module.exports = { verify, __internals: { analyseBreadth, scrubKeys, probe, summarise, detectRedaction, REFERENCE_CANDIDATES, REFERENCE_POLICIES, DFS_PLATFORMS, BASE } };