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.
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 };
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));
}
Database schema
Run this in the Supabase SQL editor. Tracks slips, individual lines, and provides a P&L summary view.
src/storage/schemas.sql
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 [];
}
}
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 },
},
});
Docker setup
Containerize the app with Redis as a companion service.
docker-compose.yml
Dockerfile
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);
});
});
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.
Non-deterministic scoring
The rolls system ensures variety in generated slips while still favoring higher-probability combinations. Do not replace with a deterministic sort.
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.
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.
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.
Aggressive caching
Cache API responses for 8 hours. Sports data does not change that fast during the day. Avoid hammering APIs.
Under-direction penalty
Unders are statistically correlated in ways that inflate apparent edge. The -5% penalty per under in a combo was empirically tuned.
Same-team contention
Betting Over on two players' points from the same team is correlated risk. The penalty addresses this.
API endpoints quick reference
All external API endpoints used by the system.
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 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 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 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 | Simpler pipeline |
| Convention-based adapters | TypeScript interface enforcement | New platforms cannot skip required methods |
| redis npm package | ioredis | Better performance, cluster support |
| No env validation | zod schema | Fail fast on missing config |