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>
82 lines
3.1 KiB
JavaScript
82 lines
3.1 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* De-vig engine (Model Train, step 1) — the foundation for EV + value.
|
||
*
|
||
* A two-sided prop price carries the book's vig (the "overround"): the two
|
||
* sides' implied probabilities sum to MORE than 1. De-vigging strips that back
|
||
* out to FAIR probabilities that sum to 1, and converts them back to a fair
|
||
* (vig-free) price per side.
|
||
*
|
||
* METHOD: multiplicative / proportional de-vig — each side's implied prob is
|
||
* divided by the total. It's the standard, distribution-free two-way method
|
||
* (a.k.a. "normalized implied probability"). Simple, transparent, and correct
|
||
* for two-way markets; we note it in the stored `method` field.
|
||
*
|
||
* If only one side is priced, fair values are UNAVAILABLE (null) — never faked.
|
||
*/
|
||
|
||
const round3 = (n) => Math.round(n * 1000) / 1000;
|
||
|
||
/** American odds → implied probability (WITH the vig). Null on bad input. */
|
||
function americanToImpliedProb(american) {
|
||
const a = Number(american);
|
||
if (!Number.isFinite(a) || a === 0) return null;
|
||
return a > 0 ? 100 / (a + 100) : (-a) / ((-a) + 100);
|
||
}
|
||
|
||
/** American odds → decimal odds (total return multiple incl. stake). */
|
||
function americanToDecimal(american) {
|
||
const a = Number(american);
|
||
if (!Number.isFinite(a) || a === 0) return null;
|
||
return a > 0 ? 1 + a / 100 : 1 + 100 / (-a);
|
||
}
|
||
|
||
/** Probability → fair American odds. Null outside (0,1). */
|
||
function impliedProbToAmerican(p) {
|
||
if (!Number.isFinite(p) || p <= 0 || p >= 1) return null;
|
||
// Even money (50%) is +100 by convention → favorites (p > .5) go negative.
|
||
return p > 0.5 ? -Math.round((p / (1 - p)) * 100) : Math.round(((1 - p) / p) * 100);
|
||
}
|
||
|
||
/**
|
||
* Two-way de-vig. Given BOTH sides' American odds, return fair prob + fair price
|
||
* per side plus the overround. Null when a side's odds are missing/invalid —
|
||
* the caller marks fair as unavailable rather than inventing it.
|
||
*/
|
||
function devigTwoWay(overOdds, underOdds) {
|
||
const po = americanToImpliedProb(overOdds);
|
||
const pu = americanToImpliedProb(underOdds);
|
||
if (po == null || pu == null) return null;
|
||
const sum = po + pu; // > 1 by the vig
|
||
if (!(sum > 0)) return null;
|
||
const fairOver = po / sum;
|
||
const fairUnder = pu / sum;
|
||
return {
|
||
method: 'multiplicative',
|
||
overround: round3(sum - 1),
|
||
over: { fair_prob: round3(fairOver), fair_odds: impliedProbToAmerican(fairOver) },
|
||
under: { fair_prob: round3(fairUnder), fair_odds: impliedProbToAmerican(fairUnder) },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Expected value (%) of staking one unit on `american` when the model gives the
|
||
* side probability `modelProb`. EV per unit = p·decimal − 1. Positive = the bet
|
||
* is +EV at the price you'd actually pay (the vig is already baked into the
|
||
* actual price, so this is the true "does it pay you" measure). Null on bad input.
|
||
*/
|
||
function evPct(modelProb, american) {
|
||
const dec = americanToDecimal(american);
|
||
if (!Number.isFinite(modelProb) || modelProb <= 0 || dec == null) return null;
|
||
return Math.round((modelProb * dec - 1) * 1000) / 10; // one decimal place
|
||
}
|
||
|
||
module.exports = {
|
||
americanToImpliedProb,
|
||
americanToDecimal,
|
||
impliedProbToAmerican,
|
||
devigTwoWay,
|
||
evPct,
|
||
};
|