From 6ede5da675c2cbdff26f22d7324dae5cf31def9d Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 00:30:57 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20odds=20calculation=20engine=20=E2=80=94?= =?UTF-8?q?=20ELO-based=20betting=20odds=20with=20house=20edge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts ELO ratings + streaks + recent form into betting odds. Supports decimal, fractional, and American formats. Includes bet validation, payout calculation, and 27 tests. Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 2 +- server/src/engine/odds.test.ts | 151 +++++++++++++++++++++++++++++++++ server/src/engine/odds.ts | 127 +++++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 server/src/engine/odds.test.ts create mode 100644 server/src/engine/odds.ts diff --git a/PLAN.md b/PLAN.md index 71cbe04..d74fa10 100644 --- a/PLAN.md +++ b/PLAN.md @@ -427,7 +427,7 @@ Integrated into the fight viewer: - [ ] Cashu mint integration - [ ] Bet escrow system - [ ] Lightning deposit/withdraw -- [ ] Odds calculation engine +- [x] Odds calculation engine - [ ] Payout automation - [ ] Bet history + verification diff --git a/server/src/engine/odds.test.ts b/server/src/engine/odds.test.ts new file mode 100644 index 0000000..a13a818 --- /dev/null +++ b/server/src/engine/odds.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest' +import { + eloProbability, + calculateOdds, + oddsToFractional, + oddsToAmerican, + calculatePayout, + validateBet, +} from './odds.js' + +describe('eloProbability', () => { + it('equal ELO gives 50/50', () => { + expect(eloProbability(1200, 1200)).toBeCloseTo(0.5, 5) + }) + + it('higher ELO favored', () => { + expect(eloProbability(1400, 1200)).toBeGreaterThan(0.7) + }) + + it('lower ELO underdog', () => { + expect(eloProbability(1000, 1400)).toBeLessThan(0.15) + }) + + it('probabilities are complementary', () => { + const pA = eloProbability(1300, 1100) + const pB = eloProbability(1100, 1300) + expect(pA + pB).toBeCloseTo(1, 5) + }) + + it('extreme ELO difference stays finite', () => { + const p = eloProbability(100, 2500) + expect(p).toBeGreaterThan(0) + expect(p).toBeLessThan(0.001) + expect(isFinite(p)).toBe(true) + }) +}) + +describe('calculateOdds', () => { + it('equal ELO gives near-even odds', () => { + const odds = calculateOdds(1200, 1200) + expect(odds.botAWinProb).toBeCloseTo(0.5, 1) + expect(odds.botBWinProb).toBeCloseTo(0.5, 1) + expect(odds.botADecimalOdds).toBeCloseTo(2.0, 0) + }) + + it('house edge reduces payout below fair odds', () => { + const odds = calculateOdds(1200, 1200, { houseEdge: 0.05 }) + expect(odds.botAPayoutMultiplier).toBeLessThan(odds.botADecimalOdds) + expect(odds.botBPayoutMultiplier).toBeLessThan(odds.botBDecimalOdds) + }) + + it('favorite has lower payout multiplier', () => { + const odds = calculateOdds(1500, 1200) + expect(odds.botAPayoutMultiplier).toBeLessThan(odds.botBPayoutMultiplier) + }) + + it('streak adjusts probability', () => { + const noStreak = calculateOdds(1200, 1200) + const withStreak = calculateOdds(1200, 1200, { streakA: 5 }) + expect(withStreak.botAWinProb).toBeGreaterThan(noStreak.botAWinProb) + }) + + it('probabilities always sum to 1', () => { + const odds = calculateOdds(1600, 1000, { streakA: 3, streakB: 1 }) + expect(odds.botAWinProb + odds.botBWinProb).toBeCloseTo(1, 2) + }) + + it('probabilities capped between 2% and 98%', () => { + const odds = calculateOdds(3000, 500, { streakA: 5 }) + expect(odds.botAWinProb).toBeLessThanOrEqual(0.98) + expect(odds.botBWinProb).toBeGreaterThanOrEqual(0.02) + }) + + it('spread reflects ELO difference', () => { + const odds = calculateOdds(1500, 1200) + expect(odds.spread).toBe(300) + }) +}) + +describe('oddsToFractional', () => { + it('even money', () => { + expect(oddsToFractional(2.0)).toBe('1/1') + }) + + it('3/1', () => { + expect(oddsToFractional(4.0)).toBe('3/1') + }) + + it('1/2', () => { + expect(oddsToFractional(1.5)).toBe('1/2') + }) +}) + +describe('oddsToAmerican', () => { + it('underdog shows positive', () => { + expect(oddsToAmerican(3.0)).toBe('+200') + }) + + it('favorite shows negative', () => { + expect(oddsToAmerican(1.5)).toBe('-200') + }) + + it('even money', () => { + expect(oddsToAmerican(2.0)).toBe('+100') + }) +}) + +describe('calculatePayout', () => { + it('100 sats at 2x = 200', () => { + expect(calculatePayout(100, 2.0)).toBe(200) + }) + + it('floors to integer sats', () => { + expect(calculatePayout(100, 1.94)).toBe(194) + }) +}) + +describe('validateBet', () => { + it('valid bet', () => { + expect(validateBet(1000).valid).toBe(true) + }) + + it('below minimum', () => { + const r = validateBet(50) + expect(r.valid).toBe(false) + expect(r.error).toContain('Minimum') + }) + + it('above maximum', () => { + const r = validateBet(200_000) + expect(r.valid).toBe(false) + expect(r.error).toContain('Maximum') + }) + + it('zero is invalid', () => { + expect(validateBet(0).valid).toBe(false) + }) + + it('negative is invalid', () => { + expect(validateBet(-100).valid).toBe(false) + }) + + it('non-integer is invalid', () => { + expect(validateBet(100.5).valid).toBe(false) + }) + + it('custom limits', () => { + expect(validateBet(50, 10, 100).valid).toBe(true) + expect(validateBet(5, 10, 100).valid).toBe(false) + }) +}) diff --git a/server/src/engine/odds.ts b/server/src/engine/odds.ts new file mode 100644 index 0000000..3e96f2f --- /dev/null +++ b/server/src/engine/odds.ts @@ -0,0 +1,127 @@ +// 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 } +}