Part 3 — Infrastructure and Reference

Cache, automation, persistence, and the complete build reference

Redis caching, Steel.dev browser sessions, Supabase storage, the CLI entry point, Docker, testing, and all reference materials including build order, battle-tested lessons, and API endpoints.

Steps 12–18Reference appendicesBuild order
Step 12

Redis cache layer

Cache-through with TTL and deduplication of concurrent requests.

src/storage/redis.ts

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.
 */
export async function cached<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttlSeconds: number = 3600 * 8,
): Promise<T> {
  if (pendingRequests.has(key)) {
    return pendingRequests.get(key) as Promise<T>;
  }

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

    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 13

Browser submission layer

Use Steel.dev for cloud browser sessions with anti-detection. Falls back to local Playwright for development.

src/browser/session.ts

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

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

    async type(selector: string, text: string) {
      await this.click(selector);
      for (const char of text) {
        await page.keyboard.type(char);
        const delay = Math.exp(Math.random() * 1.2 + 2.5);
        await new Promise((r) => setTimeout(r, delay));
      }
    },

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

    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 14

Database schema

Run this in the Supabase SQL editor. Tracks slips, individual lines, and provides a P&L summary view.

src/storage/schemas.sql

SQL Schema-- 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, 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, 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 15

CLI entry point

Wire everything together with Commander. The analyze command fetches lines, finds +EV edges, generates combinations, scores them, and selects the best slips.

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

    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 16

Config validation and logger

Zod schema for environment validation. Fail fast on missing config.

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 17

Docker setup

Containerize the app with Redis as a companion service.

docker-compose.yml

docker-compose.ymlservices: 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

DockerfileFROM 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 18

Testing

Vitest unit tests for the probability engine -- the most critical calculations.

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', () => {
    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);
  });
});
Appendix A

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
Appendix B

Key lessons from the original codebase

These patterns were battle-tested and should be preserved.

1

Non-deterministic scoring

The rolls system ensures variety in generated slips while still favoring higher-probability combinations. Do not replace with a deterministic sort.

2

Player and stat caps

The original used 12% player cap and 10% stat cap. A single player appearing in every slip is a correlated risk disaster.

3

Multi-source probability

Combine 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

Every platform returns data in a different format. Normalizers translate to a common Line type. New platforms only need client, normalizer, and submit.

5

Aggressive caching

Cache API responses for 8 hours. Sports data does not change that fast during the day. Avoid hammering APIs.

6

Under-direction penalty

Unders are statistically correlated in ways that inflate apparent edge. The -5% penalty per under in a combo was empirically tuned.

7

Same-team contention

Betting Over on two players' points from the same team is correlated risk. The penalty addresses this.

Appendix C

API endpoints quick reference

All external API endpoints used by the system.

API 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 # Steel.dev (API key in WebSocket URL) wss://connect.steel.dev?apiKey=KEY # ETR (browser scrape, requires login) https://establishtherun.com/daily-nba-full-statistical-projections # Betr Picks (GraphQL, JWT auth) POST https://api.betr.app/graphql # Sleeper (REST, no auth for reading) GET https://api.sleeper.app/picks/available/{sport} # DraftKings Pick 6 (REST) GET https://api.draftkings.com/pick6/projections?sport={sport}
Appendix D

What changed from the original

Key differences between the original 2023 codebase and this 2026 modernization.

Original (2023)New (2026)Why
JavaScript (no types)TypeScript strictCatch bugs, enforce adapter contracts
Puppeteer + stealth pluginPlaywright + Steel.dev / CamoufoxBetter anti-detect, cloud sessions, CAPTCHA solving
moment.jsdayjs / native Intlmoment is deprecated, 300KB+
7 platforms (4 dead)2 platforms (PrizePicks + Underdog)Focus on what is alive and profitable
2 platforms (PrizePicks + Underdog)7 platforms (PrizePicks, Underdog, Betr Picks, Sleeper, DK Pick 6, ParlayPlay, Fliff)Cover all active +EV opportunities
Manual odds onlyThe Odds API + OddsPapiAutomated sharp consensus for +EV
No testsVitest for probability engine + scoringPrevent regressions when tuning model
console.logpino structured loggingSearchable, filterable, production-ready
CSV bridge to PythonMonte Carlo in TypeScriptSimpler pipeline
Convention-based adaptersTypeScript interface enforcementNew platforms cannot skip required methods
redis npm packageioredisBetter performance, cluster support
No env validationzod schemaFail fast on missing config