Part 2 — Core Engine and Odds Sources

Five odds sources, sport-specific models, and the scoring system

Pull sharp projections from ETR, DataGolf, The Odds API, manual books, and Fliff. Model probabilities with Normal CDF, Poisson distribution, and Monte Carlo simulation. Generate and score optimal slip combinations across all sports.

Steps 7–115 odds sourcesSport-specific models
Step 7

Establish The Run projections

ETR provides daily NBA statistical projections via CSV download. Scrape the projections with Puppeteer, parse with PapaParse, and use as the baseline for probability calculations.

src/odds/etr.ts

import { createSession } from '../browser/session.js';
import { parse } from 'papaparse';
import { logger } from '../utils/logger.js';

const ETR_LOGIN = 'https://establishtherun.com/login';
const ETR_PROJECTIONS = 'https://establishtherun.com/daily-nba-full-statistical-projections';

/**
 * Scrape ETR daily NBA projections.
 * Login with browser, navigate to projections page,
 * download CSV, parse into projection records.
 */
export async function fetchETRProjections(
  email: string,
  password: string,
): Promise<ETRProjection[]> {
  const { browser, page } = await createSession();

  try {
    // Login
    await page.goto(ETR_LOGIN);
    await page.fill('input[name="email"]', email);
    await page.fill('input[name="password"]', password);
    await page.click('button[type="submit"]');
    await page.waitForNavigation();

    // Download projections CSV
    await page.goto(ETR_PROJECTIONS);
    const csvContent = await page.evaluate(() => {
      // Intercept CSV download link and fetch content
      const link = document.querySelector('a[href*="csv"]');
      return link ? fetch(link.href).then(r => r.text()) : '';
    });

    const { data } = parse(csvContent, { header: true, skipEmptyLines: true });
    return data as ETRProjection[];
  } finally {
    await browser.close();
  }
}

interface ETRProjection {
  player_name: string;
  team: string;
  points: number;
  rebounds: number;
  assists: number;
  steals: number;
  blocks: number;
  turnovers: number;
  three_pointers: number;
  fantasy_points: number;
}
ETR as the NBA sharp line

ETR projections serve as the "sharp line" for NBA props. When a DFS platform's line diverges from ETR's projection, that's a potential +EV opportunity.

Step 8

DataGolf and the golf strokes-gained model

DataGolf provides player-level projections with strokes-gained decomposition. Use Normal CDF with course-specific adjustments to calculate Over/Under probability for golf props.

src/odds/datagolf.ts

import cdf from '@stdlib/stats-base-dists-normal-cdf';
import { config } from '../config.js';
import { logger } from '../utils/logger.js';

const DG_BASE = 'https://feeds.datagolf.com';

interface DGPlayerProjection {
  player_name: string;
  dk_id: string;
  projected_strokes: number;
  projected_fantasy_pts: number;
  sg_total: number;       // Strokes gained total
  sg_app: number;         // Strokes gained approach
  sg_arg: number;         // Strokes gained around the green
  sg_ott: number;         // Strokes gained off the tee
  sg_putt: number;        // Strokes gained putting
}

/**
 * Fetch DataGolf projections for a tournament.
 * Includes strokes-gained decomposition per player.
 */
export async function fetchDataGolfProjections(
  tour: 'pga' | 'euro' | 'liv' = 'pga',
): Promise<DGPlayerProjection[]> {
  const url = `${DG_BASE}/preds/fantasy-projection-defaults` +
    `?tour=${tour}&key=${config.DATAGOLF_API_KEY}&site=draftkings`;

  const res = await fetch(url);
  if (!res.ok) throw new Error(`DataGolf API error: ${res.status}`);
  return res.json();
}

/**
 * Golf-specific probability using Normal CDF.
 * Uses strokes-gained model: expected strokes vs. platform line.
 *
 * For strokes props:
 *   P(Under) = CDF(line - expected, 0, stdDev)
 *   P(Over) = 1 - P(Under)
 *
 * For birdies/bogeys: use Poisson distribution instead.
 */
export function golfStrokesProbability(
  expectedStrokes: number,
  lineValue: number,
  stdDeviation: number,
  direction: 'Over' | 'Under',
): number {
  const gap = expectedStrokes - lineValue;
  const underProb = cdf(gap, 0, stdDeviation);
  return direction === 'Under' ? underProb : 1 - underProb;
}
Strokes-gained decomposition

DataGolf's strokes-gained model is the gold standard for golf projections. It decomposes player skill into approach, short game, driving, and putting — giving more accurate predictions than raw scoring averages.

Step 9

Sharp odds — +EV identification

This is the core edge. Compare DFS platform lines against sharp sportsbook consensus to find +EV props.

src/odds/the-odds-api.ts

import { config } from '../config.js';
import { logger } from '../utils/logger.js';

// The Odds API — https://the-odds-api.com/liveapi/guides/v4/
// Free tier: 500 credits/month. Each call costs (markets * regions) credits.

const BASE = 'https://api.the-odds-api.com/v4';

interface OddsApiOutcome {
  name: string;        // Player name
  description: string; // "Over" or "Under"
  price: number;       // American odds (-110, +120, etc.)
  point: number;       // The line (24.5)
}

interface OddsApiMarket {
  key: string;         // e.g. "player_points"
  outcomes: OddsApiOutcome[];
}

interface OddsApiBookmaker {
  key: string;         // e.g. "draftkings", "fanduel"
  title: string;
  markets: OddsApiMarket[];
}

export async function fetchPlayerProps(
  sportKey: string,
  markets: string[],
): Promise<Map<string, { overProb: number; underProb: number; line: number }>> {

  const eventsUrl = `${BASE}/sports/${sportKey}/events?apiKey=${config.THE_ODDS_API_KEY}`;
  const eventsRes = await fetch(eventsUrl);
  const events: any[] = await eventsRes.json();

  const allProps = new Map<string, { overProb: number; underProb: number; line: number }>();

  for (const event of events) {
    const marketsParam = markets.join(',');
    const oddsUrl = `${BASE}/sports/${sportKey}/events/${event.id}/odds` +
      `?apiKey=${config.THE_ODDS_API_KEY}` +
      `&regions=us` +
      `&markets=${marketsParam}` +
      `&oddsFormat=american`;

    const oddsRes = await fetch(oddsUrl);
    if (!oddsRes.ok) continue;
    const oddsData = await oddsRes.json();

    for (const bookmaker of (oddsData.bookmakers ?? []) as OddsApiBookmaker[]) {
      for (const market of bookmaker.markets) {
        for (const outcome of market.outcomes) {
          const key = `${outcome.name}|${market.key}|${outcome.point}`;
          const impliedProb = americanToImplied(outcome.price);

          if (!allProps.has(key)) {
            allProps.set(key, { overProb: 0, underProb: 0, line: outcome.point });
          }
          const entry = allProps.get(key)!;

          if (outcome.description === 'Over') {
            entry.overProb = entry.overProb ? (entry.overProb + impliedProb) / 2 : impliedProb;
          } else {
            entry.underProb = entry.underProb ? (entry.underProb + impliedProb) / 2 : impliedProb;
          }
        }
      }
    }
  }

  return allProps;
}

/** Convert American odds to implied probability (0-1). Removes vig naively. */
function americanToImplied(odds: number): number {
  if (odds > 0) {
    return 100 / (odds + 100);
  } else {
    return Math.abs(odds) / (Math.abs(odds) + 100);
  }
}

src/engine/ev.ts

import type { Line } from '../sports/models.js';
import { logger } from '../utils/logger.js';

/**
 * Enriches DFS lines with sharp market probabilities and calculates +EV.
 *
 * The core insight: DFS platforms set their own lines (projections).
 * Sharp sportsbooks (Pinnacle, consensus of DraftKings/FanDuel/BetMGM) set
 * market-efficient lines. When a DFS line diverges from the sharp consensus,
 * that's a potential +EV edge.
 *
 * Example: PrizePicks sets Jokic Points at 26.5.
 *          Sharp consensus (average of 5 books) implies Over at 57%.
 *          PrizePicks pays ~1.8x for a correct 2-pick.
 *          Breakeven is 55.5%. 57% > 55.5% = +EV.
 */
export function enrichWithEV(
  dfsLines: Line[],
  sharpOdds: Map<string, { overProb: number; underProb: number; line: number }>,
  profitThreshold: number,
): Line[] {
  const enriched: Line[] = [];

  for (const line of dfsLines) {
    const playerName = `${line.firstName} ${line.lastName}`;
    const marketKey = propTypeToOddsApiMarket(line.propType);
    const lookupKey = `${playerName}|${marketKey}|${line.propValue}`;

    const sharp = sharpOdds.get(lookupKey);
    if (!sharp) {
      enriched.push(line);
      continue;
    }

    const sharpProb = line.betDirection === 'Over' ? sharp.overProb : sharp.underProb;
    const ev = sharpProb - profitThreshold;

    enriched.push({
      ...line,
      sharpProbability: sharpProb,
      ev,
    });

    if (ev > 0.02) {
      logger.info({
        player: playerName,
        prop: line.propType,
        value: line.propValue,
        direction: line.betDirection,
        sharpProb: (sharpProb * 100).toFixed(1) + '%',
        ev: (ev * 100).toFixed(1) + '%',
      }, '+EV edge found');
    }
  }

  return enriched;
}

function propTypeToOddsApiMarket(propType: string): string {
  const map: Record<string, string> = {
    'Points': 'player_points',
    'Rebounds': 'player_rebounds',
    'Assists': 'player_assists',
    'Steals': 'player_steals',
    'Blocks': 'player_blocks',
    'Turnovers': 'player_turnovers',
    '3-Pointers Made': 'player_threes',
    'Pass Yards': 'player_pass_yds',
    'Rush Yards': 'player_rush_yds',
    'Receiving Yards': 'player_reception_yds',
    'Receptions': 'player_receptions',
    'Strikeouts': 'pitcher_strikeouts',
  };
  return map[propType] ?? propType.toLowerCase().replace(/\s+/g, '_');
}
The +EV formula

When the sharp market consensus gives a prop a higher probability than the DFS platform's breakeven threshold, that's a positive expected value edge. The system compares sharp implied probability against the profit threshold (54.5% for standard payouts) to find these edges.

Step 10

Probability engine

Port the original codebase's probability calculations. Normal CDF for golf and any prop with known mean and standard deviation. Poisson distribution for low-count integer stats. Monte Carlo simulation for NBA historical stats.

src/engine/probability.ts

import cdf from '@stdlib/stats-base-dists-normal-cdf';
import { mean, standardDeviation } from 'simple-statistics';

/**
 * Normal CDF probability.
 * Used for golf (DataGolf strokes-gained model) and any prop with
 * a known mean + standard deviation.
 */
export function normalCdfProbability(
  expectedValue: number,
  lineValue: number,
  stdDeviation: number,
  direction: 'Over' | 'Under',
): number {
  const gap = expectedValue - lineValue;
  const underProb = cdf(gap, 0, stdDeviation);
  return direction === 'Under' ? underProb : 1 - underProb;
}

/**
 * Monte Carlo simulation probability.
 * Takes a player's recent game stats for a prop type,
 * computes mean and std dev, then runs N simulations
 * drawing from a normal distribution.
 */
export function monteCarloSimProbability(
  recentStats: number[],
  lineValue: number,
  direction: 'Over' | 'Under',
  nSimulations: number = 10_000,
): number {
  if (recentStats.length < 3) return 0;

  const mu = mean(recentStats);
  const sigma = standardDeviation(recentStats);
  if (sigma === 0) return mu > lineValue ? (direction === 'Over' ? 1 : 0) : (direction === 'Under' ? 1 : 0);

  let count = 0;
  for (let i = 0; i < nSimulations; i++) {
    const simulated = randomNormal(mu, sigma);
    if (direction === 'Under' && simulated < lineValue) count++;
    if (direction === 'Over' && simulated > lineValue) count++;
  }
  return count / nSimulations;
}

/**
 * Poisson CDF probability.
 * Used for counting stats: 3-pointers made, blocks, steals, turnovers.
 * Better than Normal for low-count integer stats.
 *
 * P(Under line) = CDF(line - 1, lambda)
 * Adjusted for discrete intervals at the line value.
 */
import poissonCdf from '@stdlib/stats-base-dists-poisson-cdf';
import poissonPmf from '@stdlib/stats-base-dists-poisson-pmf';

export function poissonProbability(
  expectedValue: number,
  lineValue: number,
  direction: 'Over' | 'Under',
): number {
  const underOdds = poissonCdf(lineValue - 1, expectedValue) /
    (poissonCdf(lineValue - 1, expectedValue) +
     (1 - poissonCdf(lineValue - 1, expectedValue) - poissonPmf(lineValue, expectedValue)));
  return direction === 'Under' ? underOdds : 1 - underOdds;
}

/**
 * Combined probability: weighted average of ETR model probability,
 * DataGolf projection, Monte Carlo simulation, and sharp market odds.
 * Sharp odds get highest weight because the market is the most
 * efficient predictor.
 */
export function combinedProbability(
  modelProb?: number,
  simProb?: number,
  sharpProb?: number,
): number {
  const weights: [number, number][] = [];
  if (modelProb != null && modelProb > 0) weights.push([modelProb, 0.3]);
  if (simProb != null && simProb > 0) weights.push([simProb, 0.3]);
  if (sharpProb != null && sharpProb > 0) weights.push([sharpProb, 0.4]);

  if (weights.length === 0) return 0;

  const totalWeight = weights.reduce((sum, [_, w]) => sum + w, 0);
  return weights.reduce((sum, [prob, w]) => sum + prob * (w / totalWeight), 0);
}

/** Box-Muller transform for normal random. */
function randomNormal(mean: number = 0, std: number = 1): number {
  let u = 0, v = 0;
  while (u === 0) u = Math.random();
  while (v === 0) v = Math.random();
  const num = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
  return num * std + mean;
}
Three distribution models, sport-specific routing

Normal CDF handles continuous stats like points, yards, and strokes. Poisson handles discrete low-count stats like 3-pointers, blocks, and steals. Monte Carlo simulates from historical game logs when projections are unavailable. The engine routes each prop to the appropriate model based on sport and stat type. Sharp odds get the highest weight (0.4) because the market is the most efficient predictor.

Step 11

Combination generation and scoring

Generate all valid C(n,k) combinations, score them with a multi-factor system including stochastic rolls, and select the best slips with player and stat caps.

src/engine/combinations.ts

import type { Line } from '../sports/models.js';

/**
 * Generate all C(n, k) combinations of lines, filtering invalid ones.
 * Invalid combo = same player appears twice OR all lines from one team.
 */
export function generateValidCombinations(lines: Line[], slipSize: number): Line[][] {
  const result: Line[][] = [];
  const stack: { index: number; combination: Line[] }[] = [{ index: 0, combination: [] }];

  while (stack.length > 0) {
    const { index, combination } = stack.pop()!;

    if (combination.length === slipSize) {
      if (isValidCombination(combination)) {
        result.push(combination);
      }
      continue;
    }

    for (let i = index; i < lines.length; i++) {
      const name = `${lines[i].firstName} ${lines[i].lastName}`;
      const hasSamePlayer = combination.some(
        (item) => `${item.firstName} ${item.lastName}` === name,
      );

      if (!hasSamePlayer) {
        stack.push({ index: i + 1, combination: [...combination, lines[i]] });
      }
    }
  }

  return result;
}

function isValidCombination(combo: Line[]): boolean {
  const players = new Set(combo.map((l) => `${l.firstName} ${l.lastName}`));
  if (players.size !== combo.length) return false;

  const teams = new Set(combo.map((l) => l.team));
  if (teams.size <= 1) return false;

  return true;
}

src/engine/scoring.ts

import type { Line } from '../sports/models.js';

interface ScoringOptions {
  maxRolls?: number;
  underPenalty?: number;
  lowPropPenalty?: number;
  contentionPenalty?: number;
  fantasyPenalty?: number;
}

/**
 * Score a combination of lines. Higher = better.
 *
 * The scoring system is intentionally non-deterministic. Lines are ranked by
 * probability, and higher-ranked lines get more "rolls" (random draws).
 * This ensures the best slips appear most frequently while still including
 * slightly less optimal but still +EV combinations.
 */
export function scoreCombination(
  combo: Line[],
  allLines: Line[],
  options: ScoringOptions = {},
): number {
  const {
    maxRolls = 2,
    underPenalty = -0.05,
    lowPropPenalty = -0.075,
    contentionPenalty = -0.05,
    fantasyPenalty = -0.05,
  } = options;

  const maxScore = 0.725;

  let totalScore = 0;
  let unders = 0;
  let lowProps = 0;
  let fantasyProps = 0;
  const contendingStats: Record<string, Record<string, Record<string, number>>> = {};

  for (const line of combo) {
    const rank = allLines.findIndex((l) => l.probability === line.probability);
    const percentile = 1 - rank / allLines.length;

    const rollsGiven = Math.max(1.5, maxRolls * percentile);
    const fullRolls = Math.floor(rollsGiven);
    const partialRoll = Math.random() < (rollsGiven - fullRolls) ? 1 : 0;
    const totalRolls = fullRolls + partialRoll;

    const maxRollValue = Math.min(line.probability ?? 0.5, maxScore);
    const minRollValue = maxRollValue / 5;

    let lineScore = 0;
    for (let i = 0; i < totalRolls; i++) {
      lineScore += Math.max(Math.random() * maxRollValue, minRollValue);
    }
    totalScore += lineScore;

    if (line.betDirection === 'Under') unders++;
    if (line.propType === 'Fantasy Points') fantasyProps++;

    const lowThresholds: Record<string, number> = {
      '3-Pointers Made': 3.5, Turnovers: 2.5, Steals: 2.5,
      Points: 8.5, 'Fantasy Points': 12.5, 'Pts+Asts': 9.5,
      'Pts+Rebs': 9.5, 'Pts+Rebs+Asts': 10.5,
    };
    if (lowThresholds[line.propType] && line.propValue <= lowThresholds[line.propType]) {
      lowProps++;
    }

    const team = line.team;
    contendingStats[team] ??= {};
    contendingStats[team][line.propType] ??= {};
    contendingStats[team][line.propType][line.betDirection] =
      (contendingStats[team][line.propType][line.betDirection] ?? 0) + 1;
  }

  let contentionScore = 0;
  for (const team of Object.values(contendingStats)) {
    for (const [propType, directions] of Object.entries(team)) {
      for (const count of Object.values(directions)) {
        if (count > 1) {
          const penalty = propType === 'Points' ? 3 : 0.75;
          contentionScore += penalty * (count - 1);
        }
      }
    }
  }

  const adjustments =
    unders * underPenalty * totalScore +
    lowProps * lowPropPenalty * totalScore +
    contentionScore * contentionPenalty * totalScore +
    fantasyProps * fantasyPenalty * totalScore;

  return totalScore + adjustments;
}

src/engine/picker.ts

import type { Line, Slip } from '../sports/models.js';
import { scoreCombination } from './scoring.js';

interface PickerOptions {
  unitSize: number;
  maxAtRisk: number;
  limitPlayerRatio?: number;
  limitPropRatio?: number;
}

/**
 * Select the best slips from scored combinations.
 * Enforces caps on how often a single player or stat can appear
 * across all selected slips — prevents overexposure.
 */
export function pickSlips(
  scoredCombinations: { combo: Line[]; score: number }[],
  options: PickerOptions,
): Slip[] {
  const { unitSize, maxAtRisk, limitPlayerRatio = 0.12, limitPropRatio = 0.1 } = options;

  const sorted = [...scoredCombinations].sort((a, b) => b.score - a.score);
  const maxSlips = Math.floor(maxAtRisk / unitSize);
  const playerCap = Math.ceil(limitPlayerRatio * maxSlips);
  const statCap = Math.ceil(limitPropRatio * maxSlips);

  const playerCounts: Record<string, number> = {};
  const statCounts: Record<string, number> = {};
  const selected: Slip[] = [];

  for (const { combo, score } of sorted) {
    if (selected.length >= maxSlips) break;

    const underPlayerCap = combo.every((line) => {
      const name = `${line.firstName} ${line.lastName}`;
      return (playerCounts[name] ?? 0) + 1 <= playerCap;
    });

    const underStatCap = combo.every((line) => {
      const key = `${line.firstName} ${line.lastName}|${line.propType}`;
      return (statCounts[key] ?? 0) + 1 <= statCap;
    });

    if (underPlayerCap && underStatCap) {
      for (const line of combo) {
        const name = `${line.firstName} ${line.lastName}`;
        const statKey = `${name}|${line.propType}`;
        playerCounts[name] = (playerCounts[name] ?? 0) + 1;
        statCounts[statKey] = (statCounts[statKey] ?? 0) + 1;
      }

      const probabilities = combo.map((l) => l.probability ?? 0.5);
      selected.push({
        lines: combo,
        betSize: unitSize,
        score,
        totalProbability: probabilities.reduce((a, b) => a * b, 1),
      });
    }
  }

  return selected;
}
Non-deterministic by design

The scoring system uses stochastic rolls intentionally. Higher-probability lines get more rolls but the randomness ensures variety in generated slips. Do not replace this with a deterministic sort. Player caps (12%) and stat caps (10%) prevent overexposure to correlated risk.