Coverage for packages / core / ratings / time_weighted_elo.py: 0%
38 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-08 08:37 +1200
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-08 08:37 +1200
1"""Time-weighted Elo rating adjustments.
3Foundational stub for a time-weighted K-factor that increases rating updates
4for recent races (where the entity's form may have changed) and decreases
5them for stale data.
7The core idea:
8 - If a horse/driver/trainer raced recently, their rating is likely still
9 accurate, so we use the **base K-factor**.
10 - If they have **not raced recently**, their true ability may have drifted.
11 We **increase K** to let new race results have a larger impact, allowing
12 ratings to adapt faster.
13 - A **decay function** maps ``days_since_last_race`` to a multiplier on the
14 base K-factor.
16This module is **not yet integrated** into the main ``RatingEngine``.
17Integration requires calling ``compute_effective_k`` from
18``RatingEngine.get_effective_k_factor`` in ``engine.py``.
19"""
21from __future__ import annotations
23import math
24from datetime import date
27class TimeWeightedElo:
28 """Adjust K-factor based on time since last race.
30 Entities that have not raced recently receive a higher effective K-factor,
31 allowing their ratings to converge faster toward their current ability.
33 Two decay strategies are provided:
35 **Linear decay** (``mode="linear"``):
36 ``multiplier = 1 + slope × days_since_last_race``
37 Increases linearly with inactivity, bounded by ``max_multiplier``.
39 **Exponential decay** (``mode="exp"``):
40 ``multiplier = 1 + (max_multiplier - 1) × (1 - exp(-rate × days))``
41 Approaches ``max_multiplier`` asymptotically — more gradual.
42 """
44 def __init__(
45 self,
46 mode: str = "linear",
47 slope: float = 0.01,
48 rate: float = 0.005,
49 max_multiplier: float = 3.0,
50 min_days: int = 0,
51 ) -> None:
52 """Initialise time-weighted K-factor configuration.
54 Args:
55 mode: Decay function — ``"linear"`` or ``"exp"``.
56 slope: Slope for linear mode (K increase per inactive day).
57 rate: Rate constant for exponential mode.
58 max_multiplier: Upper bound on the K multiplier.
59 min_days: Minimum inactive days before multiplier > 1.
60 """
61 self.mode = mode
62 self.slope = slope
63 self.rate = rate
64 self.max_multiplier = max_multiplier
65 self.min_days = min_days
67 def compute_multiplier(self, days_since_last_race: int) -> float:
68 """Compute K-factor multiplier based on inactive days.
70 Args:
71 days_since_last_race: Number of days since the entity last raced.
73 Returns:
74 A multiplier >= 1.0 to apply to the base K-factor.
75 """
76 days = max(0, days_since_last_race - self.min_days)
77 if days <= 0:
78 return 1.0
80 if self.mode == "linear":
81 mult = 1.0 + self.slope * days
82 elif self.mode == "exp":
83 mult = 1.0 + (self.max_multiplier - 1.0) * (
84 1.0 - math.exp(-self.rate * days)
85 )
86 else:
87 raise ValueError(f"Unknown mode: {self.mode}")
89 return min(mult, self.max_multiplier)
91 def compute_effective_k(
92 self,
93 race_date: date | None,
94 last_race_date: date | None,
95 base_k: float,
96 ) -> float:
97 """Compute effective K-factor for an entity.
99 Args:
100 race_date: Date of the current race being processed.
101 last_race_date: Date of the entity's most recent prior race
102 (or *None* for first-time starters).
103 base_k: Base K-factor from settings (``elo_k_base``).
105 Returns:
106 Adjusted effective K-factor.
107 """
108 if race_date is None or last_race_date is None:
109 # First race or missing dates — use max multiplier
110 return base_k * self.max_multiplier
112 days_since = (race_date - last_race_date).days
113 multiplier = self.compute_multiplier(max(0, days_since))
114 return base_k * multiplier
117# ── Example / test usage ─────────────────────────────────────────────────
120def _demo() -> None:
121 """Demonstrate time-weighted K-factor in action."""
122 tw = TimeWeightedElo(mode="linear", slope=0.02, max_multiplier=4.0)
124 base_k = 32.0
125 today = date(2026, 5, 8)
127 scenarios = [
128 ("Raced yesterday", date(2026, 5, 7)),
129 ("Raced 1 week ago", date(2026, 5, 1)),
130 ("Raced 1 month ago", date(2026, 4, 8)),
131 ("Raced 3 months ago", date(2026, 2, 8)),
132 ("First race (no last date)", None),
133 ]
135 print("Time-Weighted Elo — K-factor adjustment demo")
136 print(
137 f" Base K = {base_k}, mode = {tw.mode}, slope = {tw.slope}, max_mult = {tw.max_multiplier}"
138 )
139 print()
141 for label, last_date in scenarios:
142 eff_k = tw.compute_effective_k(today, last_date, base_k)
143 days = (today - last_date).days if last_date else "—"
144 print(f" {label:25s} days={str(days):>5s} K_eff={eff_k:.1f}")
147if __name__ == "__main__":
148 _demo()