DFS Prop Betting Automation Engine
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:
- Pulls player prop lines from DFS platforms (PrizePicks, Underdog Fantasy)
- Pulls sharp odds from sportsbook aggregator APIs for the same props
- Compares lines to find +EV edges (where DFS platform line diverges from sharp market consensus)
- Runs probability models (Normal CDF, Monte Carlo simulation) to score each prop
- Generates optimal slip combinations using combinatorial optimization with multi-factor scoring
- Submits slips via headless browser automation with anti-detection
- 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
Project Structure
Create this exact directory structure:
Step 1: Project Initialization
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
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}` +
`®ions=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.
- Init project — package.json, tsconfig, biome, deps
- Constants & Models — types, enums, interfaces
- Config — zod env validation
- Logger — pino setup
- Redis cache — ioredis with cache-through
- Probability engine — Normal CDF + Monte Carlo + tests
- Combination generator — C(n,k) + validation + tests
- Scoring model — multi-factor scorer + tests
- Slip picker — cap-enforced selection + tests
- +EV calculator — sharp vs DFS comparison + tests
- The Odds API client — fetch sharp odds
- PrizePicks client — fetch + normalize lines
- Underdog client — fetch + normalize lines
- CLI entry point — wire everything together
- Supabase storage — persist slips and lines
- Browser session — Steel.dev / Playwright setup
- Stealth behavior — ghost-cursor, typing simulation
- PrizePicks submit — browser-based slip entry
- Underdog submit — browser-based slip entry
- Docker — containerize with Redis
Key Lessons from the Original Codebase
These patterns were battle-tested and should be preserved:
- 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.
- 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.
- 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.
- The normalizer pattern is critical. Every platform returns data in a different format. Normalizers translate to a common
Linetype. New platforms only need client + normalizer + submit — no changes to the engine. - Cache aggressively. The original cached API responses for 8 hours. Sports data doesn't change that fast during the day. Avoid hammering APIs.
- 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.
- 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
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 |