feat: odds calculation engine — ELO-based betting odds with house edge
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
669c914afc
commit
6ede5da675
@@ -427,7 +427,7 @@ Integrated into the fight viewer:
|
|||||||
- [ ] Cashu mint integration
|
- [ ] Cashu mint integration
|
||||||
- [ ] Bet escrow system
|
- [ ] Bet escrow system
|
||||||
- [ ] Lightning deposit/withdraw
|
- [ ] Lightning deposit/withdraw
|
||||||
- [ ] Odds calculation engine
|
- [x] Odds calculation engine
|
||||||
- [ ] Payout automation
|
- [ ] Payout automation
|
||||||
- [ ] Bet history + verification
|
- [ ] Bet history + verification
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user