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

1"""Time-weighted Elo rating adjustments. 

2 

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. 

6 

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. 

15 

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""" 

20 

21from __future__ import annotations 

22 

23import math 

24from datetime import date 

25 

26 

27class TimeWeightedElo: 

28 """Adjust K-factor based on time since last race. 

29 

30 Entities that have not raced recently receive a higher effective K-factor, 

31 allowing their ratings to converge faster toward their current ability. 

32 

33 Two decay strategies are provided: 

34 

35 **Linear decay** (``mode="linear"``): 

36 ``multiplier = 1 + slope × days_since_last_race`` 

37 Increases linearly with inactivity, bounded by ``max_multiplier``. 

38 

39 **Exponential decay** (``mode="exp"``): 

40 ``multiplier = 1 + (max_multiplier - 1) × (1 - exp(-rate × days))`` 

41 Approaches ``max_multiplier`` asymptotically — more gradual. 

42 """ 

43 

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. 

53 

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 

66 

67 def compute_multiplier(self, days_since_last_race: int) -> float: 

68 """Compute K-factor multiplier based on inactive days. 

69 

70 Args: 

71 days_since_last_race: Number of days since the entity last raced. 

72 

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 

79 

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}") 

88 

89 return min(mult, self.max_multiplier) 

90 

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. 

98 

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``). 

104 

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 

111 

112 days_since = (race_date - last_race_date).days 

113 multiplier = self.compute_multiplier(max(0, days_since)) 

114 return base_k * multiplier 

115 

116 

117# ── Example / test usage ───────────────────────────────────────────────── 

118 

119 

120def _demo() -> None: 

121 """Demonstrate time-weighted K-factor in action.""" 

122 tw = TimeWeightedElo(mode="linear", slope=0.02, max_multiplier=4.0) 

123 

124 base_k = 32.0 

125 today = date(2026, 5, 8) 

126 

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 ] 

134 

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() 

140 

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}") 

145 

146 

147if __name__ == "__main__": 

148 _demo()