Build Instructions

DFS Prop Betting Automation Engine

Reference

For: An LLM building this project from scratch.
Date: April 2026
Reference codebase: ./ (the original "better/golflifters" repo)
Goal: A modern, working DFS prop betting automation system targeting PrizePicks and Underdog Fantasy with +EV identification, slip generation, and automated submission.


What You Are Building

An automated system that:

  1. Pulls player prop lines from DFS platforms (PrizePicks, Underdog Fantasy)
  2. Pulls sharp odds from sportsbook aggregator APIs for the same props
  3. Compares lines to find +EV edges (where DFS platform line diverges from sharp market consensus)
  4. Runs probability models (Normal CDF, Monte Carlo simulation) to score each prop
  5. Generates optimal slip combinations using combinatorial optimization with multi-factor scoring
  6. Submits slips via headless browser automation with anti-detection
  7. Tracks results in a database for backtesting and P&L monitoring

This is a CLI tool, not a web app. It runs as a scheduled job or manually from the terminal.


Tech Stack

Tech StackRuntime: Node.js 22 LTS (ES modules) Language: TypeScript 5.x (strict mode) Browser: Playwright + Steel.dev cloud sessions (or self-hosted Camoufox fallback) HTTP Client: undici (Node built-in) for normal requests Database: Supabase (PostgreSQL) for persistence Cache: Redis 7+ with ioredis Odds Data: The Odds API (free tier) + OddsPapi (sharp books) DFS Lines: PrizePicks unofficial API + Underdog community scraper endpoints Stats Data: nba_api (Python subprocess), DataGolf API (golf), ESPN hidden API Math: @stdlib/stats for CDF, simple-statistics for Monte Carlo Formatting: Biome (replaces Prettier + ESLint) Testing: Vitest CI: GitHub Actions Package Manager: pnpm

Project Structure

Create this exact directory structure:

Directory Treebetter/ ├── src/ │ ├── index.ts # CLI entry point (commander) │ ├── config.ts # Env validation with zod │ ├── constants.ts # Sport enums, prop types, thresholds │ │ │ ├── platforms/ # Platform adapters (the adapter pattern) │ │ ├── adapter.ts # Abstract PlatformAdapter interface │ │ ├── prizepicks/ │ │ │ ├── client.ts # PrizePicks API client │ │ │ ├── normalize.ts # Raw API → Line model │ │ │ ├── submit.ts # Browser-based slip submission │ │ │ └── types.ts # PrizePicks-specific API types │ │ ├── underdog/ │ │ │ ├── client.ts # Underdog API client │ │ │ ├── normalize.ts # Raw API → Line model │ │ │ ├── submit.ts # Browser-based slip submission │ │ │ └── types.ts # Underdog-specific API types │ │ └── registry.ts # Platform registry for CLI dispatch │ │ │ ├── odds/ # Sharp odds aggregation │ │ ├── the-odds-api.ts # The Odds API client │ │ ├── oddspapi.ts # OddsPapi client (Pinnacle, sharp books) │ │ └── consensus.ts # Multi-book implied probability calculator │ │ │ ├── sports/ # Sport-specific models & logic │ │ ├── models.ts # Line, Slip, PropType interfaces │ │ ├── basketball.ts # NBA-specific enrichment & prop types │ │ ├── golf.ts # Golf strokes-gained model │ │ ├── football.ts # NFL prop types │ │ └── tennis.ts # Tennis prop types │ │ │ ├── engine/ # Core analysis & generation engine │ │ ├── probability.ts # Normal CDF, Poisson, Monte Carlo sim │ │ ├── combinations.ts # C(n,k) generator with validation │ │ ├── scoring.ts # Multi-factor slip scoring │ │ ├── picker.ts # Slip selection with player/stat caps │ │ └── ev.ts # +EV calculation (DFS line vs sharp consensus) │ │ │ ├── browser/ # Browser automation layer │ │ ├── session.ts # Steel.dev session manager (or Camoufox) │ │ ├── stealth.ts # Ghost cursor, typing sim, behavioral │ │ └── captcha.ts # CAPTCHA solving integration │ │ │ ├── data/ # External data source clients │ │ ├── datagolf.ts # DataGolf API (golf projections) │ │ ├── nba-stats.ts # NBA stats (wraps nba_api Python) │ │ ├── espn.ts # ESPN hidden API │ │ └── historical.ts # Historical stats loader │ │ │ ├── storage/ # Persistence layer │ │ ├── supabase.ts # Supabase client + queries │ │ ├── redis.ts # Redis cache-through utility │ │ └── schemas.sql # Database schema │ │ │ └── utils/ # Shared utilities │ ├── logger.ts # Pino structured logger │ ├── csv.ts # CSV read/write with PapaParse │ └── retry.ts # Exponential backoff helper │ ├── tests/ │ ├── engine/ │ │ ├── probability.test.ts # Unit tests for probability calculations │ │ ├── scoring.test.ts # Unit tests for scoring model │ │ ├── combinations.test.ts # Unit tests for combo generation │ │ └── ev.test.ts # Unit tests for +EV calculator │ ├── platforms/ │ │ ├── prizepicks.test.ts # Normalizer tests with fixtures │ │ └── underdog.test.ts # Normalizer tests with fixtures │ └── fixtures/ # Mock API response data │ ├── prizepicks-projections.json │ └── underdog-lines.json │ ├── scripts/ │ ├── fetch-lines.ts # Standalone: fetch + display lines │ ├── analyze.ts # Standalone: run analysis on fetched lines │ └── submit.ts # Standalone: submit from CSV │ ├── .env.example ├── package.json ├── tsconfig.json ├── biome.json ├── vitest.config.ts ├── Dockerfile ├── docker-compose.yml # App + Redis └── README.md

Step 1: Project Initialization

Terminalmkdir better && cd better pnpm init

package.json

{
  "name": "better",
  "version": "2.0.0",
  "type": "module",
  "engines": { "node": ">=22" },
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "biome check .",
    "lint:fix": "biome check --fix .",
    "fetch": "tsx scripts/fetch-lines.ts",
    "analyze": "tsx scripts/analyze.ts",
    "submit": "tsx scripts/submit.ts"
  }
}

Install dependencies

Terminal# Core pnpm add typescript tsx @types/node # Math & Stats pnpm add @stdlib/stats-base-dists-normal-cdf @stdlib/stats-base-dists-poisson-cdf simple-statistics # HTTP & API pnpm add zod dotenv # Browser automation pnpm add playwright ghost-cursor-playwright # Database & Cache pnpm add @supabase/supabase-js ioredis # Data processing pnpm add papaparse @types/papaparse lodash-es @types/lodash-es dayjs # Logging & CLI pnpm add pino commander chalk # Dev pnpm add -D vitest @biomejs/biome @types/node

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "sourceMap": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist", "tests"]
}

.env.example

# Supabase
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=your-anon-key

# Redis
REDIS_URL=redis://localhost:6379

# Odds APIs
THE_ODDS_API_KEY=your-key
ODDSPAPI_KEY=your-key

# DataGolf
DATAGOLF_API_KEY=your-key

# Steel.dev (cloud browser)
STEEL_API_KEY=your-key

# Platform credentials
PRIZEPICKS_EMAIL=
PRIZEPICKS_PASSWORD=
UNDERDOG_EMAIL=
UNDERDOG_PASSWORD=

# Operational
LOG_LEVEL=info
DRY_RUN=true
MAX_AT_RISK=500
UNIT_SIZE=10

Step 2: Core Type System

src/constants.ts

export const Sport = {
  NBA: 'nba',
  NFL: 'nfl',
  Golf: 'golf',
  Tennis: 'tennis',
  MLB: 'mlb',
} as const;
export type Sport = (typeof Sport)[keyof typeof Sport];

export const BetDirection = {
  Over: 'Over',
  Under: 'Under',
} as const;
export type BetDirection = (typeof BetDirection)[keyof typeof BetDirection];

export const Platform = {
  PrizePicks: 'prizepicks',
  Underdog: 'underdog',
} as const;
export type Platform = (typeof Platform)[keyof typeof Platform];

export const BasketballProp = {
  Points: 'Points',
  Rebounds: 'Rebounds',
  Assists: 'Assists',
  Steals: 'Steals',
  Blocks: 'Blocks',
  Turnovers: 'Turnovers',
  ThreePointMade: '3-Pointers Made',
  FantasyPoints: 'Fantasy Points',
  PointsAssists: 'Pts+Asts',
  PointsRebounds: 'Pts+Rebs',
  ReboundsAssists: 'Rebs+Asts',
  PointsReboundsAssists: 'Pts+Rebs+Asts',
  BlocksSteals: 'Blks+Stls',
} as const;
export type BasketballProp = (typeof BasketballProp)[keyof typeof BasketballProp];

// Profit thresholds — the minimum implied probability required to bet.
// These come from the payout structure of each platform.
// A 2-pick at PrizePicks pays 3x, so breakeven is 1/3 = 0.333.
// But each leg needs to independently exceed this threshold.
export const ProfitThreshold = {
  prizepicks: 0.545,   // ~54.5% win rate needed for +EV on standard payouts
  underdog: 0.545,
} as const;

src/sports/models.ts

This is the core type system. Every platform normalizer must produce Line objects. Every engine function operates on Line[] and Slip[].

import type { BetDirection, Sport } from '../constants.js';

export interface Line {
  // Identity
  firstName: string;
  lastName: string;
  team: string;
  sport: Sport;

  // The prop
  propType: string;           // e.g. "Points", "Rebounds", "Strokes"
  propValue: number;          // e.g. 24.5
  betDirection: BetDirection;  // Over or Under

  // Platform-specific
  platform: string;
  platformPropId: string;     // ID needed to submit the pick
  eventName: string;          // e.g. "Lakers vs Celtics"

  // Calculated (enriched by engine)
  probability?: number;       // Our model's estimated probability (0-1)
  sharpProbability?: number;  // Sharp market implied probability
  ev?: number;                // Expected value edge
  simProbability?: number;    // Monte Carlo simulated probability

  // Submission metadata
  identifyingInfo?: Record<string, unknown>;
}

export interface Slip {
  lines: Line[];
  betSize: number;
  score?: number;
  totalProbability?: number;
  expectedPayout?: number;
}

Step 3: Platform Adapter Interface

src/platforms/adapter.ts

Every platform must implement this interface. This is the most important architectural contract. The original codebase used convention; we enforce it with TypeScript.

import type { Line, Slip } from '../sports/models.js';
import type { Sport } from '../constants.js';

export interface PlatformAdapter {
  readonly name: string;
  readonly supportedSports: Sport[];

  /** Fetch raw prop lines from the platform and normalize to Line[] */
  getLines(sport: Sport): Promise<Line[]>;

  /** Submit a slip to the platform via browser automation */
  submitSlip(slip: Slip): Promise<{ success: boolean; error?: string }>;
}

Step 4: PrizePicks Client (Primary Target)

src/platforms/prizepicks/client.ts

PrizePicks has an unofficial REST API. Authentication is Keycloak-based (OAuth2/JWT). The projections endpoint is the most accessible.

import { z } from 'zod';
import type { Sport } from '../../constants.js';
import type { Line } from '../../sports/models.js';
import { normalizePrizePicks } from './normalize.js';
import { logger } from '../../utils/logger.js';

const PP_BASE = 'https://api.prizepicks.com';

// The projections endpoint does NOT require auth for reading.
// It returns all active projections across all sports.
const PROJECTIONS_URL = `${PP_BASE}/projections`;

interface PPProjection {
  id: string;
  type: string;
  attributes: {
    adjusted_odds: number | null;
    board_time: string;
    description: string;
    display_stat: string;
    end_time: string;
    flash_sale_line_score: number | null;
    game_id: string;
    image_url: string;
    in_play: boolean;
    is_promo: boolean;
    league: string;
    league_id: number;
    line_score: number;
    odds_type: string;
    projection_type: string;
    refundable: boolean;
    start_time: string;
    stat_type: string;
    status: string;
    tv_channel: string | null;
    updated_at: string;
  };
  relationships: {
    new_player: { data: { id: string; type: string } };
    league: { data: { id: string; type: string } };
    game: { data: { id: string; type: string } };
  };
}

interface PPPlayer {
  id: string;
  type: string;
  attributes: {
    display_name: string;
    first_name: string;
    last_name: string;
    image_url: string;
    league: string;
    league_id: number;
    market: string;
    name: string;
    position: string;
    team: string;
    team_name: string;
  };
}

export async function fetchPrizePicksLines(sport: Sport): Promise<Line[]> {
  const leagueMap: Record<string, string> = {
    nba: 'NBA',
    nfl: 'NFL',
    golf: 'PGA',
    tennis: 'TENNIS',
    mlb: 'MLB',
  };
  const league = leagueMap[sport];
  if (!league) throw new Error(`Unsupported sport for PrizePicks: ${sport}`);

  // Fetch projections — this is the main data endpoint.
  // The response is JSON:API format with `data` (projections) and `included` (players, games, leagues).
  const url = `${PROJECTIONS_URL}?per_page=1000&single_stat=true&league_id=${getLeagueId(sport)}`;
  logger.info({ url, sport }, 'Fetching PrizePicks projections');

  const response = await fetch(url, {
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      // No auth header needed for projections read
    },
  });

  if (!response.ok) {
    throw new Error(`PrizePicks API error: ${response.status} ${response.statusText}`);
  }

  const body = await response.json();
  const projections: PPProjection[] = body.data;
  const included: (PPPlayer | any)[] = body.included;

  // Build player lookup from included resources
  const players = new Map<string, PPPlayer>();
  for (const item of included) {
    if (item.type === 'new_player') {
      players.set(item.id, item);
    }
  }

  return normalizePrizePicks(projections, players, sport);
}

function getLeagueId(sport: Sport): number {
  // These IDs are empirically determined from the API.
  // They may change — if lines come back empty, re-discover by
  // fetching /projections without league_id filter and grouping by league_id.
  const ids: Record<string, number> = {
    nba: 7,
    nfl: 2,
    mlb: 3,
    golf: 16,
    tennis: 24,
  };
  return ids[sport] ?? 7;
}

src/platforms/prizepicks/normalize.ts

import type { Line } from '../../sports/models.js';
import type { Sport, BetDirection } from '../../constants.js';

export function normalizePrizePicks(
  projections: any[],
  players: Map<string, any>,
  sport: Sport,
): Line[] {
  const lines: Line[] = [];

  for (const proj of projections) {
    const attrs = proj.attributes;
    if (attrs.status !== 'pre_game') continue; // Only pre-game lines

    const playerId = proj.relationships.new_player.data.id;
    const player = players.get(playerId);
    if (!player) continue;

    const playerAttrs = player.attributes;

    // Each projection creates two lines: Over and Under
    for (const direction of ['Over', 'Under'] as BetDirection[]) {
      lines.push({
        firstName: playerAttrs.first_name,
        lastName: playerAttrs.last_name,
        team: playerAttrs.team ?? 'UNK',
        sport,
        propType: attrs.stat_type,
        propValue: attrs.line_score,
        betDirection: direction,
        platform: 'prizepicks',
        platformPropId: proj.id,
        eventName: attrs.description ?? `${playerAttrs.team_name}`,
        identifyingInfo: {
          league: attrs.league,
          odds_type: attrs.odds_type,
          projection_type: attrs.projection_type,
          game_id: attrs.game_id,
        },
      });
    }
  }

  return lines;
}

Step 5: Underdog Fantasy Client

src/platforms/underdog/client.ts

Underdog uses an internal REST API. The community scraper reveals the endpoints. Auth requires a browser login flow to capture cookies.

import type { Sport } from '../../constants.js';
import type { Line } from '../../sports/models.js';
import { normalizeUnderdog } from './normalize.js';
import { logger } from '../../utils/logger.js';

// These endpoints are from the community scraper (github.com/aidanhall21/underdog-fantasy-pickem-scraper).
// They may require auth cookies captured from a browser session.
const UD_API = 'https://api.underdogfantasy.com';
const LINES_URL = `${UD_API}/beta/v5/over_under_lines`;
const TEAMS_URL = 'https://stats.underdogfantasy.com/v1/teams';

// NOTE: The API version has likely incremented since v3.
// If v5 fails, try v6, v7 etc. The response schema is usually the same.
// Alternatively, intercept the live app's network requests to discover the current version.

export async function fetchUnderdogLines(
  sport: Sport,
  authCookies?: string,
): Promise<Line[]> {
  const headers: Record<string, string> = {
    'Accept': 'application/json',
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
  };

  if (authCookies) {
    headers['Cookie'] = authCookies;
  }

  const [linesRes, teamsRes] = await Promise.all([
    fetch(LINES_URL, { headers }),
    fetch(TEAMS_URL, { headers }),
  ]);

  if (!linesRes.ok) {
    // If 401/403, cookies may be expired — trigger browser re-auth
    throw new Error(`Underdog API error: ${linesRes.status}. Re-auth may be needed.`);
  }

  const linesData = await linesRes.json();
  const teamsData = await teamsRes.json();

  return normalizeUnderdog(linesData, teamsData, sport);
}

src/platforms/underdog/normalize.ts

Model this after the original platforms/underdog/normalize/nba.js. The key insight from the original code: Underdog's API returns over_under_lines with nested appearance_stat, appearances, players, and games. You must join these by ID.

import type { Line } from '../../sports/models.js';
import type { Sport, BetDirection } from '../../constants.js';

// Maps Underdog's stat keys to our normalized prop type names.
// This is sport-specific — expand for NFL, Golf, etc.
const STAT_MAP: Record<string, string> = {
  points: 'Points',
  rebounds: 'Rebounds',
  assists: 'Assists',
  steals: 'Steals',
  blocks: 'Blocks',
  turnovers: 'Turnovers',
  three_points_made: '3-Pointers Made',
  pts_asts: 'Pts+Asts',
  pts_rebs: 'Pts+Rebs',
  rebs_asts: 'Rebs+Asts',
  pts_rebs_asts: 'Pts+Rebs+Asts',
  blk_stls: 'Blks+Stls',
  fantasy_points: 'Fantasy Points',
  // Golf
  strokes: 'Strokes',
  birdies: 'Birdies',
  bogeys: 'Bogeys',
  // NFL
  pass_yards: 'Pass Yards',
  rush_yards: 'Rush Yards',
  receiving_yards: 'Receiving Yards',
  receptions: 'Receptions',
  completions: 'Completions',
};

export function normalizeUnderdog(
  linesData: any,
  teamsData: any,
  sport: Sport,
): Line[] {
  const lines: Line[] = [];

  // Build lookup maps
  const playersById = new Map<string, any>();
  for (const p of linesData.players ?? []) playersById.set(p.id, p);

  const gamesById = new Map<string, any>();
  for (const g of linesData.games ?? []) gamesById.set(g.id, g);

  const teamsById = new Map<string, any>();
  for (const t of teamsData.teams ?? []) teamsById.set(t.id, t);

  const appearancesById = new Map<string, any>();
  for (const a of linesData.appearances ?? []) appearancesById.set(a.id, a);

  for (const ouLine of linesData.over_under_lines ?? []) {
    const stat = ouLine.over_under?.appearance_stat?.stat;
    const propType = STAT_MAP[stat];
    if (!propType) continue;

    const appearanceId = ouLine.over_under?.appearance_stat?.appearance_id;
    const appearance = appearancesById.get(appearanceId);
    if (!appearance) continue;

    const player = playersById.get(appearance.player_id);
    if (!player) continue;

    // Filter by sport
    if (player.sport_id?.toLowerCase() !== sport) continue;

    const game = gamesById.get(appearance.match_id);
    const team = teamsById.get(appearance.team_id);

    let eventName = 'Unknown';
    if (game) {
      const away = teamsById.get(game.away_team_id);
      const home = teamsById.get(game.home_team_id);
      eventName = `${home?.abbr ?? '?'} vs ${away?.abbr ?? '?'}`;
    }

    for (const option of ouLine.options ?? []) {
      const direction: BetDirection = option.choice === 'higher' ? 'Over' : 'Under';
      lines.push({
        firstName: player.first_name,
        lastName: player.last_name,
        team: team?.abbr ?? 'UNK',
        sport,
        propType,
        propValue: parseFloat(ouLine.stat_value),
        betDirection: direction,
        platform: 'underdog',
        platformPropId: option.over_under_line_id,
        eventName,
        identifyingInfo: {
          overUnderId: ouLine.over_under_id,
          choice: option.choice,
        },
      });
    }
  }

  return lines;
}

Step 6: 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.
// Player props: /v4/sports/{sport}/events/{eventId}/odds?markets=player_points,player_rebounds...

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,      // e.g. "basketball_nba"
  markets: string[],     // e.g. ["player_points", "player_rebounds", "player_assists"]
): Promise<Map<string, { overProb: number; underProb: number; line: number }>> {

  // Step 1: Get events (games) for the sport
  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 }>();

  // Step 2: For each event, fetch player prop odds
  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();

    // Step 3: Aggregate across bookmakers to find consensus probability
    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') {
            // Average across books for consensus
            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) {
    // Build lookup key matching The Odds API format
    const playerName = `${line.firstName} ${line.lastName}`;
    const marketKey = propTypeToOddsApiMarket(line.propType);
    const lookupKey = `${playerName}|${marketKey}|${line.propValue}`;

    const sharp = sharpOdds.get(lookupKey);
    if (!sharp) {
      // No sharp odds available — fall back to model probability only
      enriched.push(line);
      continue;
    }

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

    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, '_');
}

Step 7: Probability Engine

src/engine/probability.ts

Port the original codebase's probability calculations. The original used Normal CDF for golf (strokes gained model) and Monte Carlo simulation for NBA (historical stats).

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.
 *
 * From the original: predict.js uses this to calculate P(Under) = CDF(gap, 0, std_dev)
 * where gap = expected_value - line_value.
 */
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.
 * Used for NBA (from the original generate_sim.js).
 *
 * 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.
 *
 * Returns P(direction) = count(sim satisfies direction) / N
 */
export function monteCarloSimProbability(
  recentStats: number[],
  lineValue: number,
  direction: 'Over' | 'Under',
  nSimulations: number = 10_000,
): number {
  if (recentStats.length < 3) return 0; // Not enough data

  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;
}

/**
 * Combined probability: weighted average of model probability, simulation, and sharp odds.
 * This is the final probability used for scoring.
 */
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]); // Sharp gets highest weight

  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. Same as original generate_sim.js. */
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;
}

Step 8: Combination Generation & Scoring

src/engine/combinations.ts

Port the original generateCombinations from basketball/generateSlips.js. This is a stack-based C(n,k) generator that filters invalid combos (same player, same team).

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

/**
 * Generate all C(n, k) combinations of lines, filtering invalid ones.
 * Ported from the original basketball/generateSlips.js.
 *
 * 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 {
  // No duplicate players
  const players = new Set(combo.map((l) => `${l.firstName} ${l.lastName}`));
  if (players.size !== combo.length) return false;

  // Must have players from more than one team
  const teams = new Set(combo.map((l) => l.team));
  if (teams.size <= 1) return false;

  return true;
}

src/engine/scoring.ts

Port the original scoring model from basketball/generateSlips.js. This is the "secret sauce" — the multi-factor scoring system with stochastic rolls, under-direction penalty, low-prop penalty, stat contention penalty, and fantasy points penalty.

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.
 *
 * Ported from the original basketball/generateSlips.js scoreCombination().
 *
 * 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.
 *
 * Penalties reduce the score for:
 * - Too many Under bets (under-direction bias)
 * - Low prop values (riskier, less predictable)
 * - Stat contention (conflicting props on same team)
 * - Fantasy points props (harder to predict accurately)
 */
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) {
    // Calculate percentile ranking among all lines
    const rank = allLines.findIndex((l) => l.probability === line.probability);
    const percentile = 1 - rank / allLines.length;

    // Higher-probability lines get more rolls (stochastic advantage)
    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;

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

    // Low prop value detection
    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++;
    }

    // Track stat contention per team
    const team = line.team;
    contendingStats[team] ??= {};
    contendingStats[team][line.propType] ??= {};
    contendingStats[team][line.propType][line.betDirection] =
      (contendingStats[team][line.propType][line.betDirection] ?? 0) + 1;
  }

  // Calculate contention score
  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);
        }
      }
    }
  }

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

  return totalScore + adjustments;
}

src/engine/picker.ts

Port the original pickSlipsByScore — selects top slips while enforcing player appearance caps and stat caps.

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.
 *
 * Ported from original basketball/generateSlips.js pickSlipsByScore().
 */
export function pickSlips(
  scoredCombinations: { combo: Line[]; score: number }[],
  options: PickerOptions,
): Slip[] {
  const { unitSize, maxAtRisk, limitPlayerRatio = 0.12, limitPropRatio = 0.1 } = options;

  // Sort by score descending
  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;

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

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

    if (underPlayerCap && underStatCap) {
      // Update counts
      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;
}

Step 9: Redis Cache Layer

src/storage/redis.ts

Port the original redis-utility.js pattern — cache-through with TTL and deduplication.

import Redis from 'ioredis';
import { config } from '../config.js';

const redis = new Redis(config.REDIS_URL);

const pendingRequests = new Map<string, Promise<any>>();

/**
 * Cache-through: check Redis, execute if miss, store with TTL.
 * Deduplicates concurrent requests for the same key.
 * Ported from original redis-utility.js.
 */
export async function cached<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttlSeconds: number = 3600 * 8, // 8 hours default, same as original
): Promise<T> {
  // Deduplicate concurrent requests
  if (pendingRequests.has(key)) {
    return pendingRequests.get(key) as Promise<T>;
  }

  const execute = async (): Promise<T> => {
    // Check cache
    const cached = await redis.get(key);
    if (cached) return JSON.parse(cached);

    // Cache miss — fetch, store, return
    const result = await fetcher();
    await redis.set(key, JSON.stringify(result), 'EX', ttlSeconds);
    return result;
  };

  const promise = execute();
  pendingRequests.set(key, promise);

  try {
    return await promise;
  } finally {
    pendingRequests.delete(key);
  }
}

export { redis };

Step 10: Browser Submission Layer

src/browser/session.ts

Use Steel.dev for cloud browser sessions. Falls back to local Playwright.

import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { config } from '../config.js';
import { logger } from '../utils/logger.js';

/**
 * Create a browser session.
 * Primary: Steel.dev cloud (anti-detect, CAPTCHA solving, residential proxy).
 * Fallback: Local Playwright (for development/testing).
 */
export async function createSession(): Promise<{ browser: Browser; context: BrowserContext; page: Page }> {
  if (config.STEEL_API_KEY) {
    return createSteelSession();
  }
  return createLocalSession();
}

async function createSteelSession() {
  // Steel.dev: connect via CDP endpoint. One-line change from local Playwright.
  // Sessions include anti-detect, fingerprinting, and optional residential proxy.
  const cdpUrl = `wss://connect.steel.dev?apiKey=${config.STEEL_API_KEY}`;
  logger.info('Connecting to Steel.dev cloud browser');

  const browser = await chromium.connectOverCDP(cdpUrl);
  const context = browser.contexts()[0] ?? await browser.newContext();
  const page = context.pages()[0] ?? await context.newPage();

  return { browser, context, page };
}

async function createLocalSession() {
  logger.info('Using local Playwright browser');
  const browser = await chromium.launch({ headless: false });
  const context = await browser.newContext({
    viewport: { width: 1366, height: 768 },
    userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
  });
  const page = await context.newPage();
  return { browser, context, page };
}

src/browser/stealth.ts

Human-like interaction helpers. Use ghost-cursor for mouse movements.

import { createCursor } from 'ghost-cursor-playwright';
import type { Page, ElementHandle } from 'playwright';

/**
 * Attach human-like mouse movement to a page.
 * Uses Bezier curves with Fitts's Law for natural motion.
 */
export async function attachHumanBehavior(page: Page) {
  const cursor = createCursor(page);

  return {
    /** Move to element and click with human-like motion */
    async click(selector: string) {
      const el = await page.waitForSelector(selector);
      if (!el) throw new Error(`Element not found: ${selector}`);
      await cursor.click(el);
      await randomDelay(100, 300);
    },

    /** Type text with human-like cadence (log-normal inter-keystroke timing) */
    async type(selector: string, text: string) {
      await this.click(selector);
      for (const char of text) {
        await page.keyboard.type(char);
        // Log-normal delay: common combos faster, uncommon slower
        const delay = Math.exp(Math.random() * 1.2 + 2.5); // ~12-60ms
        await new Promise((r) => setTimeout(r, delay));
      }
    },

    /** Small random fidget movement */
    async fidget() {
      await cursor.moveTo({
        x: Math.random() * 200 + 100,
        y: Math.random() * 200 + 100,
      });
    },

    /** Scroll naturally */
    async scroll(distance: number) {
      const steps = Math.floor(Math.abs(distance) / 100);
      for (let i = 0; i < steps; i++) {
        await page.mouse.wheel(0, distance > 0 ? 100 : -100);
        await randomDelay(50, 200);
      }
    },
  };
}

function randomDelay(min: number, max: number): Promise<void> {
  const ms = min + Math.random() * (max - min);
  return new Promise((r) => setTimeout(r, ms));
}

Step 11: Database Schema

src/storage/schemas.sql

Run this in Supabase SQL editor.

-- Slips table
CREATE TABLE slips (
  id TEXT PRIMARY KEY,
  platform TEXT NOT NULL,
  sport TEXT NOT NULL,
  bet_size NUMERIC NOT NULL,
  slip_size INT NOT NULL,
  total_probability NUMERIC,
  score NUMERIC,
  placed BOOLEAN DEFAULT FALSE,
  placed_at TIMESTAMPTZ,
  result TEXT,  -- 'win', 'loss', 'push', 'pending'
  payout NUMERIC,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Lines within slips
CREATE TABLE lines (
  id TEXT PRIMARY KEY,
  slip_id TEXT REFERENCES slips(id),
  first_name TEXT NOT NULL,
  last_name TEXT NOT NULL,
  team TEXT,
  prop_type TEXT NOT NULL,
  prop_value NUMERIC NOT NULL,
  bet_direction TEXT NOT NULL,
  platform_prop_id TEXT,
  probability NUMERIC,
  sharp_probability NUMERIC,
  ev NUMERIC,
  result TEXT,  -- 'hit', 'miss', 'push', 'pending'
  actual_value NUMERIC,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Indexes
CREATE INDEX idx_slips_platform ON slips(platform);
CREATE INDEX idx_slips_sport ON slips(sport);
CREATE INDEX idx_slips_created ON slips(created_at DESC);
CREATE INDEX idx_lines_slip ON lines(slip_id);
CREATE INDEX idx_lines_player ON lines(first_name, last_name);

-- View: P&L tracking
CREATE VIEW pnl_summary AS
SELECT
  platform,
  sport,
  DATE_TRUNC('day', created_at) AS day,
  COUNT(*) AS total_slips,
  SUM(CASE WHEN result = 'win' THEN 1 ELSE 0 END) AS wins,
  SUM(CASE WHEN result = 'loss' THEN 1 ELSE 0 END) AS losses,
  SUM(bet_size) AS total_wagered,
  SUM(COALESCE(payout, 0)) AS total_payout,
  SUM(COALESCE(payout, 0)) - SUM(bet_size) AS net_pnl
FROM slips
WHERE result IS NOT NULL
GROUP BY platform, sport, DATE_TRUNC('day', created_at)
ORDER BY day DESC;

Step 12: CLI Entry Point

src/index.ts

import { Command } from 'commander';
import { fetchPrizePicksLines } from './platforms/prizepicks/client.js';
import { fetchUnderdogLines } from './platforms/underdog/client.js';
import { fetchPlayerProps } from './odds/the-odds-api.js';
import { enrichWithEV } from './engine/ev.js';
import { monteCarloSimProbability, combinedProbability } from './engine/probability.js';
import { generateValidCombinations } from './engine/combinations.js';
import { scoreCombination } from './engine/scoring.js';
import { pickSlips } from './engine/picker.js';
import { config } from './config.js';
import { logger } from './utils/logger.js';
import { ProfitThreshold, type Sport } from './constants.js';

const program = new Command();

program
  .name('better')
  .description('DFS prop betting automation engine')
  .version('2.0.0');

program
  .command('analyze')
  .description('Fetch lines, find +EV edges, generate optimal slips')
  .requiredOption('-p, --platform <platform>', 'Platform: prizepicks or underdog')
  .requiredOption('-s, --sport <sport>', 'Sport: nba, nfl, golf, tennis, mlb')
  .option('--slip-size <n>', 'Lines per slip', '3')
  .option('--unit-size <n>', 'Bet size per slip', '10')
  .option('--max-risk <n>', 'Max total at risk', '500')
  .option('--dry-run', 'Do not submit, just display results', true)
  .action(async (opts) => {
    const sport = opts.sport as Sport;
    const platform = opts.platform;
    const slipSize = parseInt(opts.slipSize);
    const unitSize = parseInt(opts.unitSize);
    const maxAtRisk = parseInt(opts.maxRisk);

    // 1. Fetch DFS lines
    logger.info({ platform, sport }, 'Fetching DFS lines');
    let dfsLines = platform === 'prizepicks'
      ? await fetchPrizePicksLines(sport)
      : await fetchUnderdogLines(sport);
    logger.info(`Fetched ${dfsLines.length} lines`);

    // 2. Fetch sharp odds for comparison
    logger.info('Fetching sharp odds');
    const sportKey = sport === 'nba' ? 'basketball_nba' : sport === 'nfl' ? 'americanfootball_nfl' : sport;
    const markets = getMarketsForSport(sport);
    const sharpOdds = await fetchPlayerProps(sportKey, markets);
    logger.info(`Fetched sharp odds for ${sharpOdds.size} props`);

    // 3. Enrich with +EV
    const threshold = ProfitThreshold[platform as keyof typeof ProfitThreshold] ?? 0.545;
    dfsLines = enrichWithEV(dfsLines, sharpOdds, threshold);

    // 4. Filter to +EV only
    const evLines = dfsLines.filter((l) => (l.ev ?? 0) > 0);
    logger.info(`Found ${evLines.length} +EV lines`);

    if (evLines.length < slipSize) {
      logger.warn(`Not enough +EV lines (${evLines.length}) for slip size ${slipSize}`);
      return;
    }

    // 5. Assign combined probability (for scoring)
    for (const line of evLines) {
      line.probability = combinedProbability(
        line.probability,
        line.simProbability,
        line.sharpProbability,
      );
    }

    // Sort by probability descending for ranking
    const rankedLines = [...evLines].sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0));

    // 6. Generate combinations and score
    logger.info(`Generating C(${rankedLines.length}, ${slipSize}) combinations`);
    const combos = generateValidCombinations(rankedLines, slipSize);
    logger.info(`Generated ${combos.length} valid combinations`);

    const scored = combos.map((combo) => ({
      combo,
      score: scoreCombination(combo, rankedLines),
    }));

    // 7. Pick best slips with caps
    const slips = pickSlips(scored, { unitSize, maxAtRisk });
    logger.info(`Selected ${slips.length} slips (${slips.length * unitSize} total at risk)`);

    // 8. Display results
    for (const [i, slip] of slips.entries()) {
      console.log(`\n--- Slip ${i + 1} (score: ${slip.score?.toFixed(3)}) ---`);
      for (const line of slip.lines) {
        const evStr = line.ev ? ` | EV: ${(line.ev * 100).toFixed(1)}%` : '';
        console.log(
          `  ${line.betDirection} ${line.propValue} ${line.propType} — ${line.firstName} ${line.lastName} (${line.team})${evStr}`,
        );
      }
    }

    if (!opts.dryRun) {
      logger.info('Submission mode not implemented yet — use --dry-run');
    }
  });

program.parse();

function getMarketsForSport(sport: Sport): string[] {
  switch (sport) {
    case 'nba':
      return ['player_points', 'player_rebounds', 'player_assists', 'player_threes', 'player_blocks', 'player_steals'];
    case 'nfl':
      return ['player_pass_yds', 'player_rush_yds', 'player_reception_yds', 'player_receptions'];
    default:
      return [];
  }
}

Step 13: Config Validation

src/config.ts

import { z } from 'zod';
import { config as dotenvConfig } from 'dotenv';

dotenvConfig();

const schema = z.object({
  SUPABASE_URL: z.string().url(),
  SUPABASE_ANON_KEY: z.string().min(1),
  REDIS_URL: z.string().default('redis://localhost:6379'),
  THE_ODDS_API_KEY: z.string().min(1),
  ODDSPAPI_KEY: z.string().optional(),
  DATAGOLF_API_KEY: z.string().optional(),
  STEEL_API_KEY: z.string().optional(),
  PRIZEPICKS_EMAIL: z.string().optional(),
  PRIZEPICKS_PASSWORD: z.string().optional(),
  UNDERDOG_EMAIL: z.string().optional(),
  UNDERDOG_PASSWORD: z.string().optional(),
  LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'),
  DRY_RUN: z.coerce.boolean().default(true),
  MAX_AT_RISK: z.coerce.number().default(500),
  UNIT_SIZE: z.coerce.number().default(10),
});

export const config = schema.parse(process.env);

src/utils/logger.ts

import pino from 'pino';
import { config } from '../config.js';

export const logger = pino({
  level: config.LOG_LEVEL,
  transport: {
    target: 'pino-pretty',
    options: { colorize: true },
  },
});

Step 14: Docker Setup

docker-compose.yml

services:
  app:
    build: .
    env_file: .env
    depends_on:
      - redis
    volumes:
      - ./data:/app/data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

volumes:
  redis_data:

Dockerfile

FROM node:22-slim

RUN apt-get update && apt-get install -y \
  ca-certificates \
  && rm -rf /var/lib/apt/lists/*

RUN corepack enable && corepack prepare pnpm@latest --activate

WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

CMD ["node", "dist/index.js"]

Step 15: Testing

tests/engine/probability.test.ts

import { describe, it, expect } from 'vitest';
import { normalCdfProbability, monteCarloSimProbability } from '../../src/engine/probability.js';

describe('normalCdfProbability', () => {
  it('returns ~0.5 when expected equals line', () => {
    const prob = normalCdfProbability(25, 25, 5, 'Over');
    expect(prob).toBeCloseTo(0.5, 1);
  });

  it('returns high probability when expected is much higher than line (Over)', () => {
    const prob = normalCdfProbability(30, 20, 3, 'Over');
    expect(prob).toBeGreaterThan(0.95);
  });

  it('returns high probability when expected is much lower than line (Under)', () => {
    const prob = normalCdfProbability(15, 25, 3, 'Under');
    expect(prob).toBeGreaterThan(0.95);
  });
});

describe('monteCarloSimProbability', () => {
  it('returns roughly correct probability for Over', () => {
    // Player averages 25 points with std dev 5, line is 20
    const stats = Array.from({ length: 10 }, () => 25 + (Math.random() - 0.5) * 10);
    const prob = monteCarloSimProbability([25, 28, 22, 30, 24, 26, 23, 27, 29, 25], 20, 'Over');
    expect(prob).toBeGreaterThan(0.8);
  });

  it('returns 0 with insufficient data', () => {
    expect(monteCarloSimProbability([10, 20], 15, 'Over')).toBe(0);
  });
});

Build Order

Follow this sequence. Each step should compile and pass tests before moving on.

  1. Init project — package.json, tsconfig, biome, deps
  2. Constants & Models — types, enums, interfaces
  3. Config — zod env validation
  4. Logger — pino setup
  5. Redis cache — ioredis with cache-through
  6. Probability engine — Normal CDF + Monte Carlo + tests
  7. Combination generator — C(n,k) + validation + tests
  8. Scoring model — multi-factor scorer + tests
  9. Slip picker — cap-enforced selection + tests
  10. +EV calculator — sharp vs DFS comparison + tests
  11. The Odds API client — fetch sharp odds
  12. PrizePicks client — fetch + normalize lines
  13. Underdog client — fetch + normalize lines
  14. CLI entry point — wire everything together
  15. Supabase storage — persist slips and lines
  16. Browser session — Steel.dev / Playwright setup
  17. Stealth behavior — ghost-cursor, typing simulation
  18. PrizePicks submit — browser-based slip entry
  19. Underdog submit — browser-based slip entry
  20. Docker — containerize with Redis

Key Lessons from the Original Codebase

These patterns were battle-tested and should be preserved:

  1. The scoring system is intentionally non-deterministic. The "rolls" system ensures variety in generated slips while still favoring higher-probability combinations. Do not replace this with a deterministic sort.
  2. Player and stat caps prevent overexposure. The original used 12% player cap and 10% stat cap. A single player appearing in every slip is a correlated risk disaster.
  3. Probability comes from multiple sources. The original combined DataGolf skill decomposition (model), Monte Carlo simulation (historical), and market odds (sharp). The weighted combination is more robust than any single source.
  4. The normalizer pattern is critical. Every platform returns data in a different format. Normalizers translate to a common Line type. New platforms only need client + normalizer + submit — no changes to the engine.
  5. Cache aggressively. The original cached API responses for 8 hours. Sports data doesn't change that fast during the day. Avoid hammering APIs.
  6. The under-direction penalty is real. Unders are statistically correlated in ways that inflate apparent edge. The -5% penalty per under in a combo was empirically tuned.
  7. Contention between same-team props is dangerous. If you bet Over on Player A's points and Over on Player B's points on the same team, you're implicitly betting the team scores a lot — that's correlated risk. The penalty addresses this.

API Endpoints Quick Reference

Endpoints# PrizePicks (no auth for reading) GET https://api.prizepicks.com/projections?per_page=1000&single_stat=true # Underdog (may need auth cookies) GET https://api.underdogfantasy.com/beta/v5/over_under_lines GET https://stats.underdogfantasy.com/v1/teams # The Odds API (API key in query param) GET https://api.the-odds-api.com/v4/sports/{sport}/events?apiKey=KEY GET https://api.the-odds-api.com/v4/sports/{sport}/events/{id}/odds?apiKey=KEY&markets=player_points&regions=us # DataGolf (API key in query param) GET https://feeds.datagolf.com/preds/player-decompositions?tour=pga&key=KEY GET https://feeds.datagolf.com/preds/fantasy-projection-defaults?tour=pga&key=KEY&site=draftkings # ESPN (no auth) GET https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard GET https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/teams # NBA (no auth, rate-limited) # Use nba_api Python package as subprocess: # python3 -c "from nba_api.stats.endpoints import playergamelog; print(playergamelog.PlayerGameLog(player_id=203999).get_json())" # Steel.dev (API key in WebSocket URL) wss://connect.steel.dev?apiKey=KEY

What's Different from the Original

Original (2023) New (2026) Why
JavaScript (no types) TypeScript strict Catch bugs, enforce adapter contracts
Puppeteer + stealth plugin Playwright + Steel.dev / Camoufox Better anti-detect, cloud sessions, CAPTCHA solving
moment.js dayjs / native Intl moment is deprecated, 300KB+
7 platforms (4 dead) 2 platforms (PrizePicks + Underdog) Focus on what's alive and profitable
Manual odds only The Odds API + OddsPapi Automated sharp consensus for +EV
No tests Vitest for probability engine + scoring Prevent regressions when tuning model
console.log pino structured logging Searchable, filterable, production-ready
CSV bridge to Python Monte Carlo in TypeScript + optional Python subprocess Simpler pipeline, Python only when needed
Convention-based adapters TypeScript interface enforcement New platforms can't skip required methods
redis npm package ioredis Better performance, cluster support
No env validation zod schema Fail fast on missing config