Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* CLV (Closing Line Value) tracker.
|
||||
*
|
||||
* For each resolved grade, compare the line at which we graded (open) to
|
||||
* the line at game start (close). Positive CLV means the line moved
|
||||
* toward us — a leading indicator of long-term profitability that's
|
||||
* independent of whether the prop actually hit.
|
||||
*/
|
||||
|
||||
const { americanToImplied } = require('./LineShoppingEngine');
|
||||
|
||||
/**
|
||||
* @param {{graded_line:number, graded_odds:number, close_line:number, close_odds:number, direction:'over'|'under'}} entry
|
||||
*/
|
||||
function clvFor(entry) {
|
||||
if (!entry) return null;
|
||||
const dir = entry.direction;
|
||||
const gI = americanToImplied(entry.graded_odds);
|
||||
const cI = americanToImplied(entry.close_odds);
|
||||
if (gI == null || cI == null) return null;
|
||||
// Over: line went DOWN = good for us (book thinks fewer); odds went up
|
||||
// (less juice). We compute edge as (graded_implied - close_implied) for
|
||||
// Over and the negation for Under so a positive value always means CLV+.
|
||||
const oddsClv = dir === 'over' ? gI - cI : cI - gI;
|
||||
const lineDelta = entry.close_line - entry.graded_line;
|
||||
const lineClv = dir === 'over' ? -lineDelta : lineDelta;
|
||||
return {
|
||||
odds_clv: oddsClv,
|
||||
line_clv: lineClv,
|
||||
positive: oddsClv > 0 || lineClv > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function summarize(entries) {
|
||||
const items = (entries || []).map((e) => ({ ...e, clv: clvFor(e) })).filter((e) => e.clv);
|
||||
if (!items.length) return { count: 0, positive_rate: null, avg_odds_clv: null, avg_line_clv: null };
|
||||
const positive = items.filter((i) => i.clv.positive).length;
|
||||
const avgOdds = items.reduce((s, i) => s + i.clv.odds_clv, 0) / items.length;
|
||||
const avgLine = items.reduce((s, i) => s + i.clv.line_clv, 0) / items.length;
|
||||
return {
|
||||
count: items.length,
|
||||
positive_rate: positive / items.length,
|
||||
avg_odds_clv: avgOdds,
|
||||
avg_line_clv: avgLine,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { clvFor, summarize };
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Cascade engine.
|
||||
*
|
||||
* Input: an injury / lineup / weather delta + the set of props it touches.
|
||||
* Output: a cascade alert with before/after grade per affected prop.
|
||||
*
|
||||
* The actual regrade happens in the grading engine; we just compose the
|
||||
* notification payload. Persist to `cascade_alerts` and surface in the
|
||||
* dead-hours feed + notification bell.
|
||||
*/
|
||||
|
||||
function buildAlert({ trigger, before = [], after = [] } = {}) {
|
||||
if (!trigger || typeof trigger !== 'object') {
|
||||
throw new Error('cascade: trigger required');
|
||||
}
|
||||
const beforeByKey = new Map((before || []).map((p) => [p.key, p]));
|
||||
const affected = [];
|
||||
for (const a of after || []) {
|
||||
const b = beforeByKey.get(a.key);
|
||||
if (!b) continue;
|
||||
if (a.grade === b.grade) continue;
|
||||
affected.push({
|
||||
key: a.key,
|
||||
player: a.player ?? b.player,
|
||||
stat: a.stat ?? b.stat,
|
||||
old_grade: b.grade,
|
||||
new_grade: a.grade,
|
||||
old_projection: b.projection ?? null,
|
||||
new_projection: a.projection ?? null,
|
||||
direction: a.direction ?? b.direction,
|
||||
});
|
||||
}
|
||||
return {
|
||||
trigger_type: trigger.type, // 'injury' | 'lineup' | 'weather' | 'ref' | 'umpire'
|
||||
trigger_detail: trigger.detail || trigger,
|
||||
affected_props: affected,
|
||||
affected_count: affected.length,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildAlert };
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Correlation engine.
|
||||
*
|
||||
* Pearson correlation between two stat streams. Caller feeds in pairs of
|
||||
* arrays (same player or same team) and we return the coefficient plus
|
||||
* the implied SGP adjustment for value flagging.
|
||||
*/
|
||||
|
||||
function pearson(xs, ys) {
|
||||
if (!Array.isArray(xs) || !Array.isArray(ys) || xs.length !== ys.length || xs.length < 3) return null;
|
||||
let sx = 0, sy = 0;
|
||||
for (let i = 0; i < xs.length; i++) { sx += xs[i]; sy += ys[i]; }
|
||||
const mx = sx / xs.length, my = sy / ys.length;
|
||||
let num = 0, dx = 0, dy = 0;
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
const a = xs[i] - mx;
|
||||
const b = ys[i] - my;
|
||||
num += a * b; dx += a * a; dy += b * b;
|
||||
}
|
||||
const den = Math.sqrt(dx * dy);
|
||||
if (den === 0) return 0;
|
||||
return num / den;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare measured correlation to the book's implicit SGP adjustment.
|
||||
* `bookAdjustment` is the multiplier the book applies to the joint price
|
||||
* vs the independent-events price. >1 means the book over-prices the
|
||||
* correlation; <1 means under-priced (VALUE).
|
||||
*/
|
||||
function flagValue(measuredR, bookAdjustment) {
|
||||
if (measuredR == null || bookAdjustment == null) return null;
|
||||
if (bookAdjustment < 1 && measuredR > 0.15) return 'VALUE';
|
||||
if (bookAdjustment > 1.2 && measuredR < 0.1) return 'OVERPRICED';
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { pearson, flagValue };
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Expected Value calculator.
|
||||
*
|
||||
* Inputs: book odds + VYNDR's modeled probability (derived from grade tier).
|
||||
* Output: edge % and a friendly "+EV: 8.2%" string for the grade card.
|
||||
*/
|
||||
|
||||
const { americanToImplied } = require('./LineShoppingEngine');
|
||||
|
||||
// Calibrated probabilities per grade tier — these track the published Ledger.
|
||||
// Refresh from the grade_history table on a schedule.
|
||||
const GRADE_PROBABILITY = Object.freeze({
|
||||
'A+': 0.74,
|
||||
'A': 0.65,
|
||||
'A-': 0.62,
|
||||
'B+': 0.58,
|
||||
'B': 0.55,
|
||||
'B-': 0.53,
|
||||
'C+': 0.50,
|
||||
'C': 0.48,
|
||||
'C-': 0.46,
|
||||
'D': 0.40,
|
||||
'F': 0.35,
|
||||
});
|
||||
|
||||
function probabilityForGrade(grade) {
|
||||
if (!grade) return null;
|
||||
return GRADE_PROBABILITY[grade] ?? GRADE_PROBABILITY[grade[0]] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{grade:string, odds:number}} input
|
||||
* @returns {{ev_pct:number, edge_pct:number, label:string}|null}
|
||||
*/
|
||||
function calculate({ grade, odds } = {}) {
|
||||
const p = probabilityForGrade(grade);
|
||||
const implied = americanToImplied(odds);
|
||||
if (p == null || implied == null) return null;
|
||||
const edge = p - implied;
|
||||
const edgePct = edge / implied;
|
||||
const sign = edge >= 0 ? '+' : '−';
|
||||
return {
|
||||
modeled_probability: p,
|
||||
implied_probability: implied,
|
||||
edge,
|
||||
edge_pct: edgePct,
|
||||
label: `${sign}EV: ${(Math.abs(edgePct) * 100).toFixed(1)}%`,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { calculate, probabilityForGrade, GRADE_PROBABILITY };
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Line shopping — for each unique prop (game/player/stat), find the best
|
||||
* line per side across books.
|
||||
*
|
||||
* Best Over = lowest line + best odds at that line.
|
||||
* Best Under = highest line + best odds at that line.
|
||||
*
|
||||
* We also flag "outlier" books — a book that's 1+ points off the median.
|
||||
*/
|
||||
|
||||
function propKey(p) {
|
||||
return `${p.game_id}|${p.player_id ?? p.player_name}|${p.stat_type}`;
|
||||
}
|
||||
|
||||
function americanToImplied(odds) {
|
||||
if (typeof odds !== 'number' || !Number.isFinite(odds)) return null;
|
||||
return odds > 0 ? 100 / (odds + 100) : -odds / (-odds + 100);
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
function process(props) {
|
||||
const grouped = new Map();
|
||||
for (const p of props || []) {
|
||||
const key = propKey(p);
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(p);
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (const [key, rows] of grouped.entries()) {
|
||||
if (rows.length === 1) {
|
||||
out.push({ ...rows[0], best_over: rows[0], best_under: rows[0], line_outliers: [] });
|
||||
continue;
|
||||
}
|
||||
const lines = rows.map((r) => r.line).filter((n) => typeof n === 'number');
|
||||
const med = median(lines);
|
||||
|
||||
const overs = rows.filter((r) => r.odds_over != null);
|
||||
const unders = rows.filter((r) => r.odds_under != null);
|
||||
|
||||
// Best Over = lowest line, then best (highest implied prob) odds at that line.
|
||||
let bestOver = null;
|
||||
for (const r of overs) {
|
||||
if (!bestOver) { bestOver = r; continue; }
|
||||
if (r.line < bestOver.line) bestOver = r;
|
||||
else if (r.line === bestOver.line) {
|
||||
const a = americanToImplied(r.odds_over);
|
||||
const b = americanToImplied(bestOver.odds_over);
|
||||
if (a != null && b != null && a < b) bestOver = r;
|
||||
}
|
||||
}
|
||||
let bestUnder = null;
|
||||
for (const r of unders) {
|
||||
if (!bestUnder) { bestUnder = r; continue; }
|
||||
if (r.line > bestUnder.line) bestUnder = r;
|
||||
else if (r.line === bestUnder.line) {
|
||||
const a = americanToImplied(r.odds_under);
|
||||
const b = americanToImplied(bestUnder.odds_under);
|
||||
if (a != null && b != null && a < b) bestUnder = r;
|
||||
}
|
||||
}
|
||||
|
||||
const outliers = (med != null)
|
||||
? rows.filter((r) => Math.abs(r.line - med) >= 1).map((r) => ({ book: r.book, line: r.line, delta: r.line - med }))
|
||||
: [];
|
||||
|
||||
out.push({
|
||||
key,
|
||||
median_line: med,
|
||||
books: rows,
|
||||
best_over: bestOver,
|
||||
best_under: bestUnder,
|
||||
line_outliers: outliers,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { process, americanToImplied };
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Middle detection across books.
|
||||
*
|
||||
* A middle exists when one book has Over X.5 and another has Under Y.5 with
|
||||
* X < Y — any actual result in [X+1, Y-1] wins both sides. We only flag
|
||||
* middles where VYNDR's projection puts the probability of landing in the
|
||||
* middle above 15%.
|
||||
*/
|
||||
|
||||
const { americanToImplied } = require('./LineShoppingEngine');
|
||||
|
||||
function approxLandsBetween(projection, lo, hi, sigma = 5) {
|
||||
if (projection == null) return null;
|
||||
// Crude normal-ish band: pretend sigma is half the typical spread; a real
|
||||
// model would use the per-stat empirical distribution from grade_history.
|
||||
const cdf = (x) => 0.5 * (1 + Math.tanh((x - projection) / (sigma * 1.2533)));
|
||||
return cdf(hi) - cdf(lo);
|
||||
}
|
||||
|
||||
function detect(shoppedProps, { minProbability = 0.15 } = {}) {
|
||||
const middles = [];
|
||||
for (const group of shoppedProps || []) {
|
||||
const rows = group.books || [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
for (let j = 0; j < rows.length; j++) {
|
||||
if (i === j) continue;
|
||||
const a = rows[i]; // candidate Over
|
||||
const b = rows[j]; // candidate Under
|
||||
if (typeof a.line !== 'number' || typeof b.line !== 'number') continue;
|
||||
if (a.line >= b.line) continue;
|
||||
if (a.odds_over == null || b.odds_under == null) continue;
|
||||
|
||||
const middleLo = a.line + 0.5;
|
||||
const middleHi = b.line - 0.5;
|
||||
if (middleHi < middleLo) continue;
|
||||
|
||||
const prob = approxLandsBetween(group.projection ?? group.vyndr_projection, middleLo, middleHi);
|
||||
if (prob == null) continue;
|
||||
if (prob < minProbability) continue;
|
||||
|
||||
middles.push({
|
||||
key: group.key,
|
||||
over: { book: a.book, line: a.line, odds: a.odds_over, implied: americanToImplied(a.odds_over) },
|
||||
under: { book: b.book, line: b.line, odds: b.odds_under, implied: americanToImplied(b.odds_under) },
|
||||
window: [middleLo, middleHi],
|
||||
probability: prob,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return middles;
|
||||
}
|
||||
|
||||
module.exports = { detect };
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Steam detection — flags lines that move 1+ points in <2 hours.
|
||||
*
|
||||
* Inputs: a stream of { prop_key, book, line, odds, recorded_at } samples.
|
||||
* The orchestrator persists samples to `line_history` and calls check() with
|
||||
* the rolling window for tonight's slate.
|
||||
*/
|
||||
|
||||
const TWO_HOURS_MS = 2 * 60 * 60_000;
|
||||
const STEAM_THRESHOLD = 1;
|
||||
|
||||
/**
|
||||
* @param {Array<{prop_key:string, book:string, line:number, odds:number|null, recorded_at:string|number}>} samples
|
||||
* @returns {Array<{prop_key:string, book:string, from_line:number, to_line:number, delta:number, duration_ms:number, started_at:string, ended_at:string}>}
|
||||
*/
|
||||
function check(samples) {
|
||||
if (!Array.isArray(samples) || samples.length === 0) return [];
|
||||
|
||||
// Group samples by prop_key + book and sort chronologically.
|
||||
const buckets = new Map();
|
||||
for (const s of samples) {
|
||||
const k = `${s.prop_key}|${s.book}`;
|
||||
if (!buckets.has(k)) buckets.set(k, []);
|
||||
buckets.get(k).push({ ...s, t: new Date(s.recorded_at).getTime() });
|
||||
}
|
||||
|
||||
const flags = [];
|
||||
for (const [key, rows] of buckets.entries()) {
|
||||
rows.sort((a, b) => a.t - b.t);
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
// Walk forward in time and stop as soon as the gap > window.
|
||||
const start = rows[i];
|
||||
for (let j = i + 1; j < rows.length; j++) {
|
||||
const end = rows[j];
|
||||
if (end.t - start.t > TWO_HOURS_MS) break;
|
||||
const delta = end.line - start.line;
|
||||
if (Math.abs(delta) >= STEAM_THRESHOLD) {
|
||||
const [propKey, book] = key.split('|');
|
||||
flags.push({
|
||||
prop_key: propKey,
|
||||
book,
|
||||
from_line: start.line,
|
||||
to_line: end.line,
|
||||
delta,
|
||||
duration_ms: end.t - start.t,
|
||||
started_at: new Date(start.t).toISOString(),
|
||||
ended_at: new Date(end.t).toISOString(),
|
||||
});
|
||||
break; // one flag per starting sample
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
module.exports = { check, TWO_HOURS_MS, STEAM_THRESHOLD };
|
||||
Reference in New Issue
Block a user