// Odds calculation engine for BOTFIGHTS betting // Converts ELO ratings into fair betting odds with a configurable house edge export interface BettingOdds { botAWinProb: number // 0-1 probability of bot A winning botBWinProb: number // 0-1 probability of bot B winning botADecimalOdds: number // decimal odds (e.g. 2.0 = even money) botBDecimalOdds: number botAPayoutMultiplier: number // after house edge botBPayoutMultiplier: number spread: number // ELO difference (positive = A favored) } // Default house edge: 3% (97% payout) const DEFAULT_HOUSE_EDGE = 0.03 /** * Calculate win probability from ELO difference. * Uses the standard ELO expected score formula. */ export function eloProbability(eloA: number, eloB: number): number { return 1 / (1 + Math.pow(10, (eloB - eloA) / 400)) } /** * Calculate betting odds for a fight between two bots. * Incorporates ELO ratings, win streaks, and recent form. */ export function calculateOdds( eloA: number, eloB: number, opts: { streakA?: number streakB?: number recentWinRateA?: number // 0-1, last 10 fights recentWinRateB?: number houseEdge?: number } = {}, ): BettingOdds { const houseEdge = opts.houseEdge ?? DEFAULT_HOUSE_EDGE // Base probability from ELO let probA = eloProbability(eloA, eloB) // Streak adjustment: hot streaks slightly increase probability (max 5% shift) const streakA = Math.min(opts.streakA ?? 0, 5) const streakB = Math.min(opts.streakB ?? 0, 5) const streakShift = (streakA - streakB) * 0.01 probA = Math.max(0.02, Math.min(0.98, probA + streakShift)) // Recent form adjustment (max 3% shift) if (opts.recentWinRateA != null && opts.recentWinRateB != null) { const formShift = (opts.recentWinRateA - opts.recentWinRateB) * 0.03 probA = Math.max(0.02, Math.min(0.98, probA + formShift)) } const probB = 1 - probA // Fair decimal odds (no margin) const fairOddsA = 1 / probA const fairOddsB = 1 / probB // Apply house edge: reduce payout by house edge percentage const payoutRate = 1 - houseEdge const payoutA = fairOddsA * payoutRate const payoutB = fairOddsB * payoutRate return { botAWinProb: Math.round(probA * 1000) / 1000, botBWinProb: Math.round(probB * 1000) / 1000, botADecimalOdds: Math.round(fairOddsA * 100) / 100, botBDecimalOdds: Math.round(fairOddsB * 100) / 100, botAPayoutMultiplier: Math.round(payoutA * 100) / 100, botBPayoutMultiplier: Math.round(payoutB * 100) / 100, spread: eloA - eloB, } } /** * Convert decimal odds to display formats. */ export function oddsToFractional(decimal: number): string { if (decimal <= 1) return '0/1' const numerator = decimal - 1 // Find clean fraction for (const denom of [1, 2, 3, 4, 5, 6, 7, 8, 10, 20, 50, 100]) { const num = numerator * denom if (Math.abs(num - Math.round(num)) < 0.05) { return `${Math.round(num)}/${denom}` } } return `${Math.round(numerator * 100)}/100` } export function oddsToAmerican(decimal: number): string { if (decimal >= 2) { return `+${Math.round((decimal - 1) * 100)}` } return `-${Math.round(100 / (decimal - 1))}` } /** * Calculate potential payout for a bet amount. */ export function calculatePayout(betAmount: number, payoutMultiplier: number): number { return Math.floor(betAmount * payoutMultiplier) } /** * Validate a bet amount against min/max limits. */ export function validateBet( amountSats: number, minBet: number = 100, maxBet: number = 100_000, ): { valid: boolean; error?: string } { if (!Number.isInteger(amountSats) || amountSats <= 0) { return { valid: false, error: 'Bet amount must be a positive integer (sats).' } } if (amountSats < minBet) { return { valid: false, error: `Minimum bet is ${minBet} sats.` } } if (amountSats > maxBet) { return { valid: false, error: `Maximum bet is ${maxBet} sats.` } } return { valid: true } }