Files
qrank/src/services/elo.go
2025-09-18 21:15:56 +02:00

139 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// File elo implements a practical Elo rating update for head-to-head sports
// such as table soccer. This variant has:
// - No home/away asymmetry
// - Margin of victory (MoV) factor using a robust bounded form
// - Simple burn-in: higher K for the first N games
// - No draws (inputs must produce a clear winner)
// - Rating floor
// - Per-match delta cap
// - No time weighting and no explicit uncertainty modeling
package services
import (
"errors"
"math"
"github.com/ascyii/qrank/src/repository"
"github.com/ascyii/qrank/src/utils"
)
var eloCfg = struct {
Mu0 float32
Scale float32 // KNew applies to players during burn-in (first BurnInGames matches).
KNew float32
KStd float32
BurnInGames int
RatingFloor float32
MaxPerMatchDelta float32
MaxGoals int
}{
Mu0: 1500,
Scale: 400,
KNew: 48,
KStd: 24,
BurnInGames: 20,
RatingFloor: 500,
MaxPerMatchDelta: 60,
MaxGoals: 10,
}
type Team struct {
Players []*repository.GameUser
Score int
AverageRating float32
}
func NewTeam(players []*repository.GameUser, score int) *Team {
var t1s float32 = 0
for _, player := range players {
t1s += float32(player.User.Elo)
}
t1a := t1s / float32(len(players))
return &Team{Players: players, Score: score, AverageRating: t1a}
}
func GetNewElo(t1, t2 *Team) error {
// Update the teams
if t2.Score < 0 || t1.Score < 0 || t2.Score > eloCfg.MaxGoals || t1.Score > eloCfg.MaxGoals {
return errors.New("goals out of allowed range")
}
if t2.Score == t1.Score {
return errors.New("draws are not supported by this variant")
}
// Expected score (BradleyTerry with logistic base-10)
teams := []*Team{t1, t2}
for i, t := range teams {
otherTeam := teams[1-i]
for i, p := range t.Players {
newRating, err := CalculateRating(p.User.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.User.GameCount)
if err != nil {
return err
}
t.Players[i].User.Elo = utils.RoundFloat(newRating, 1)
}
}
return nil
}
func CalculateRating(rA, rB float32, goalsA, goalsB, gamesA int) (newrA float32, err error) {
// Observed score (no draws)
var sA float32
if goalsA > goalsB {
sA = 1.0
} else {
sA = 0.0
}
// Margin of victory factor with clamped goal difference
diff := goalsA - goalsB
if diff < 0 {
diff = -diff
}
if diff < 1 {
diff = 1
}
if diff > eloCfg.MaxGoals {
diff = eloCfg.MaxGoals
}
// Raw delta before caps
eA := expected(eloCfg.Scale, rA, rB)
mov := movFactor(rA, rB, diff)
var Keff float32
if gamesA <= eloCfg.BurnInGames {
Keff = eloCfg.KNew
} else {
Keff = eloCfg.KStd
}
delta := Keff * mov * (sA - eA)
// Apply symmetric per-match cap
if delta > eloCfg.MaxPerMatchDelta {
delta = eloCfg.MaxPerMatchDelta
} else if delta < -eloCfg.MaxPerMatchDelta {
delta = -eloCfg.MaxPerMatchDelta
}
return rA + delta, nil
}
func expected(scale, rA, rB float32) float32 {
return float32(
1.0 / (1.0 + math.Pow(10.0, float64(-(rA-rB)/scale))),
)
}
func movFactor(rA, rB float32, diff int) float32 {
fd := float64(diff)
return float32(
math.Log(fd+1.0) * 2.2 / (math.Abs(float64(rA-rB))*0.001 + 2.2),
)
}