Part 1 — Foundation and Platform Clients

Types, adapters, and the first two platform integrations

Initialize the TypeScript project, define the core data models across 7 platforms and 10 sports, create the platform adapter contract, and build the PrizePicks, Underdog, Betr Picks, Sleeper, DraftKings Pick 6, ParlayPlay, and Fliff clients.

Steps 1–6TypeScript strictSeven platform adapters
Step 1

Project initialization

Set up the pnpm project with TypeScript, Biome, and all dependencies.

Init project mkdir 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 # 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

Define the constants, enums, and data models. Every platform normalizer must produce Line objects. Every engine function operates on Line[] and Slip[].

src/constants.ts

export const Sport = {
  NBA: 'nba',
  NFL: 'nfl',
  Golf: 'golf',
  Tennis: 'tennis',
  MLB: 'mlb',
  CFB: 'cfb',
  WNBA: 'wnba',
  LPGA: 'lpga',
  KBO: 'kbo',
  Esports: 'esports',
  MMA: 'mma',
  Lacrosse: 'lacrosse',
  NASCAR: 'nascar',
} as const;
export type Sport = (typeof Sport)[keyof typeof Sport];

export const BetDirection = {
  Over: 'Over',
  Under: 'Under',
  Higher: 'Higher',  // Underdog rival lines
  Lower: 'Lower',    // Underdog rival lines
} as const;
export type BetDirection = (typeof BetDirection)[keyof typeof BetDirection];

export const Platform = {
  PrizePicks: 'prizepicks',
  Underdog: 'underdog',
  Betr: 'betr',
  Sleeper: 'sleeper',
  DraftKingsPick6: 'dk-pick6',
  ParlayPlay: 'parlayplay',
  Fliff: 'fliff',
} 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',
  ThreePointAttempts: '3-Pointer Attempts',
  FantasyPoints: 'Fantasy Points',
  PointsAssists: 'Pts+Asts',
  PointsRebounds: 'Pts+Rebs',
  ReboundsAssists: 'Rebs+Asts',
  PointsReboundsAssists: 'Pts+Rebs+Asts',
  BlocksSteals: 'Blks+Stls',
  DoubleDouble: 'Double Double',
} as const;
export type BasketballProp = (typeof BasketballProp)[keyof typeof BasketballProp];

export const FootballProp = {
  PassYards: 'Pass Yards',
  PassCompletions: 'Completions',
  RushYards: 'Rush Yards',
  ReceivingYards: 'Receiving Yards',
  Receptions: 'Receptions',
  Interceptions: 'Interceptions',
  PassYardsReceivingYards: 'Pass Yards + Receiving Yards',
  CompletionsReceptions: 'Completions + Receptions',
} as const;
export type FootballProp = (typeof FootballProp)[keyof typeof FootballProp];

export const GolfProp = {
  Strokes: 'Strokes',
  BirdiesOrBetter: 'Birdies or Better',
  BogeysOrWorse: 'Bogeys or Worse',
  FewerStrokes: 'Fewer Strokes',  // Rival lines (head-to-head)
} as const;
export type GolfProp = (typeof GolfProp)[keyof typeof GolfProp];

export const TennisProp = {
  Aces: 'Aces',
  DoubleFaults: 'Double Faults',
  BreakPoints: 'Break Points',
} as const;
export type TennisProp = (typeof TennisProp)[keyof typeof TennisProp];

export const EsportsProp = {
  Kills: 'Kills',
  Deaths: 'Deaths',
  Assists: 'Assists',
  HeadshotPct: 'Headshot %',
  TotalMaps: 'Total Maps',
  FirstBlood: 'First Blood',
} as const;
export type EsportsProp = (typeof EsportsProp)[keyof typeof EsportsProp];

export const WNBAProp = {
  // Same structure as BasketballProp — WNBA uses identical stat types
  ...BasketballProp,
} as const;

export const MmaProp = {
  TotalRounds: 'Total Rounds',
  SignificantStrikes: 'Significant Strikes',
  Takedowns: 'Takedowns',
  MethodOfVictory: 'Method of Victory',
} as const;
export type MmaProp = (typeof MmaProp)[keyof typeof MmaProp];

export const PayoutType = {
  Mortal: 'mortal',     // All-or-nothing
  Insured: 'insured',   // Partial payout on loss
} as const;
export type PayoutType = (typeof PayoutType)[keyof typeof PayoutType];

// Profit thresholds — minimum implied probability for +EV.
// Derived from each platform's payout structure.
export const ProfitThreshold = {
  prizepicks: 0.5434,
  underdog: 0.545,
  betr: 0.545,
  sleeper: 0.545,
  'dk-pick6': 0.545,
  parlayplay: 0.545,
  fliff: 0.545,
} as const;

src/sports/models.ts

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

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

  // The prop
  propType: string;
  propValue: number;
  betDirection: BetDirection;

  // Platform-specific
  platform: Platform;
  platformPropId: string;
  eventName: string;

  // Calculated (enriched by engine)
  probability?: number;
  sharpProbability?: number;
  ev?: number;
  simProbability?: number;

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

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

The Line interface is the core contract. Every platform client must normalize its API response into this shape. Every engine function consumes and produces Lines.

Step 3

Platform adapter interface

Every platform must implement this interface. This is the most important architectural contract.

src/platforms/adapter.ts

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 }>;
}
Convention to Contract

The original codebase used convention. This version enforces the contract with TypeScript. New platforms only need client, normalizer, and submit — no changes to the engine.

Step 4

PrizePicks client

PrizePicks has an unofficial REST API. Authentication is Keycloak-based. The projections endpoint is the most accessible and does not require auth for reading.

src/platforms/prizepicks/client.ts

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

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

  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;

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

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

    const playerAttrs = player.attributes;

    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

Underdog uses an internal REST API. Auth requires a browser login flow to capture cookies.

src/platforms/underdog/client.ts

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

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

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) {
    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

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

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',
  strokes: 'Strokes',
  birdies: 'Birdies',
  bogeys: 'Bogeys',
  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[] = [];

  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;

    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;
}
API Version May Change

The Underdog API version (v5) may have incremented. If v5 fails, try v6, v7, etc. Alternatively, intercept the live app's network requests to discover the current version.

Step 6

Sleeper, DraftKings Pick 6, and other platforms

Five additional platform adapters beyond PrizePicks and Underdog. Each follows the same adapter pattern — client, normalizer, and submit.

Betr Picks — GraphQL Props

// Betr Picks uses a GraphQL API with JWT auth.
// Covers NFL, MLB, NBA, NHL. Supports: Points, Rebounds, Assists,
// 3-pointers, Doubles, Blocks, Steals, Turnovers, combo props.

const BETR_GQL = 'https://api.betr.app/graphql';

export async function fetchBetrLines(authToken: string): Promise<Line[]> {
  const query = `{
    upcomingEvents(sport: NBA) {
      id
      players {
        id firstName lastName team
        projections { statType line }
      }
    }
  }`;

  const res = await fetch(BETR_GQL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${authToken}`,
    },
    body: JSON.stringify({ query }),
  });

  return normalizeBetr(await res.json());
}

Sleeper — Over/Under Pick'em

// Sleeper integrates pick'em DFS directly into their popular fantasy app.
// Over 40 million users. Supports NFL, NBA, MLB, NHL, and more.
// API-based line fetching similar to PrizePicks.

const SLEEPER_API = 'https://api.sleeper.app';

export async function fetchSleeperLines(sport: Sport): Promise<Line[]> {
  const res = await fetch(`${SLEEPER_API}/picks/available/${sport}`);
  if (!res.ok) throw new Error(`Sleeper API error: ${res.status}`);
  const data = await res.json();
  return normalizeSleeper(data, sport);
}

DraftKings Pick 6 — Pick'em Contests

// DraftKings Pick 6 is DK's pick'em product with massive liquidity.
// Supports all major sports. Uses DraftKings' existing player pool.
// Lines fetched via DraftKings API with contest-specific endpoints.

const DK_PICK6_API = 'https://api.draftkings.com/pick6';

export async function fetchDKPick6Lines(sport: Sport): Promise<Line[]> {
  const res = await fetch(`${DK_PICK6_API}/projections?sport=${sport}`, {
    headers: { 'Accept': 'application/json' },
  });
  if (!res.ok) throw new Error(`DK Pick 6 API error: ${res.status}`);
  const data = await res.json();
  return normalizeDKPick6(data, sport);
}

ParlayPlay — NFL Combo Workflows

// ParlayPlay focuses on NFL QB + Receiver combos.
// Uses browser automation with interactive team selection.
// Combo types: Pass Yards + Receiving Yards, Completions + Receptions.

export const NFLComboType = {
  YardsCombo: 'yards',       // QB Pass Yards + WR Receiving Yards
  CatchCombo: 'catches',     // QB Completions + WR Receptions
} as const;

export interface NFLCombo {
  qb: Line;                  // QB prop (Pass Yards or Completions)
  receiver: Line;            // WR/TE prop (Receiving Yards or Receptions)
  comboType: string;
  teams: string[];           // e.g. ['ATL', 'JAX']
}

Fliff — Sharp Odds Aggregator

// Fliff is NOT a DFS platform — it's a sharp book used for comparison.
// Fetches odds via The Odds API filtered to the Fliff bookmaker.
// NBA markets: points, rebounds, assists, threes, double_double,
// blocks, steals, turnovers, points_rebounds_assists.

const FLIFF_MARKETS = [
  'player_points', 'player_rebounds', 'player_assists',
  'player_threes', 'player_double_double', 'player_blocks',
  'player_steals', 'player_turnovers',
  'player_points_rebounds_assists',
];
Adapter Pattern

Each platform follows the same adapter pattern: client (fetch data), normalizer (convert to Line[]), and submit (browser or API). Adding a new platform requires only these three files — no changes to the engine.