c3bcfaba94
/sports and /markets/resolution-summary carry no per-prop data and no credentials, and the shape summary alone cannot answer the question they exist to answer -- whether PropLine actually GRADES the sports we cannot settle. A shape is not a number. Both bodies are scrubbed on the way out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
413 lines
17 KiB
JavaScript
413 lines
17 KiB
JavaScript
'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 '<redacted>'. 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 '<redacted>' 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('<redacted>');
|
|
}
|
|
// Defence in depth: kill any surviving apiKey=… query value.
|
|
return s.replace(/(apiKey=)[^&\s]+/gi, '$1<redacted>');
|
|
}
|
|
|
|
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 } };
|