Coverage for packages / core / ratings / recompute.py: 15%

68 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-08 08:37 +1200

1"""Recompute ratings deterministically from stored race results.""" 

2 

3from datetime import date 

4 

5from sqlalchemy.orm import Session, joinedload 

6 

7from packages.core.common.logging import get_logger 

8from packages.core.ratings.engine import RatingEngine 

9from packages.core.storage.models import Race, RatingSnapshot, Starter 

10from packages.core.storage.repositories import RaceRepository, RatingSnapshotRepository 

11 

12logger = get_logger(__name__) 

13 

14 

15def recompute_ratings( 

16 session: Session, 

17 date_from: date, 

18 date_to: date, 

19 clear_existing: bool = False, 

20 learn_adjustments: bool = False, 

21) -> int: 

22 """Recompute all ratings from scratch in date range. 

23 

24 This is deterministic: same inputs will always produce same outputs. 

25 

26 Args: 

27 session: Database session 

28 date_from: Start date (inclusive) 

29 date_to: End date (inclusive) 

30 clear_existing: If True, delete existing rating snapshots first 

31 learn_adjustments: If True, learn barrier/handicap adjustments from results 

32 

33 Returns: 

34 Number of rating snapshots created 

35 """ 

36 logger.info(f"Recomputing ratings from {date_from} to {date_to}") 

37 

38 # Clear existing snapshots if requested 

39 if clear_existing: 

40 logger.info("Clearing existing rating snapshots...") 

41 deleted = session.query(RatingSnapshot).delete() 

42 session.commit() 

43 logger.info(f"Deleted {deleted} existing rating snapshots") 

44 

45 # Initialize rating engine with database session 

46 engine = RatingEngine(db_session=session) 

47 

48 # Get all races in chronological order 

49 races = RaceRepository.get_races_for_recompute(session, date_from, date_to) 

50 logger.info(f"Processing {len(races)} races") 

51 

52 snapshot_count = 0 

53 

54 for idx, race in enumerate(races, 1): 

55 if idx % 100 == 0: 

56 logger.info( 

57 f"Processed {idx}/{len(races)} races, {snapshot_count} snapshots" 

58 ) 

59 

60 # Get starters for race with all relationships loaded 

61 starters = ( 

62 session.query(Starter) 

63 .filter(Starter.race_id == race.id) 

64 .options( 

65 joinedload(Starter.horse), 

66 joinedload(Starter.driver), 

67 joinedload(Starter.trainer), 

68 ) 

69 .all() 

70 ) 

71 

72 if not starters: 

73 logger.debug(f"Skipping race {race.id} - no starters") 

74 continue 

75 

76 # Load race.meeting for venue access (for effective rating computation) 

77 if not race.meeting: 

78 session.refresh(race, ["meeting"]) 

79 

80 # Process race and get updates 

81 updates = engine.process_race(race, starters) 

82 

83 # Learn adjustments if enabled 

84 if learn_adjustments: 

85 engine.learn_adjustments_from_race(race, starters, use_global_only=True) 

86 

87 # Save rating snapshots 

88 for update in updates: 

89 RatingSnapshotRepository.upsert( 

90 session, 

91 entity_type=update.entity_type, 

92 entity_id=update.entity_id, 

93 as_of_race_id=race.id, 

94 rating=update.new_rating, 

95 rd=update.rd, 

96 meta=update.meta, 

97 ) 

98 snapshot_count += 1 

99 

100 # Commit periodically 

101 if idx % 50 == 0: 

102 session.commit() 

103 

104 # Final commit 

105 session.commit() 

106 

107 logger.info( 

108 f"Recompute complete: processed {len(races)} races, " 

109 f"created {snapshot_count} rating snapshots" 

110 ) 

111 

112 return snapshot_count 

113 

114 

115def recompute_ratings_incremental( 

116 session: Session, 

117 race_id: int, 

118 engine: RatingEngine | None = None, 

119 learn_adjustments: bool = False, 

120) -> int: 

121 """Recompute ratings for a single race (incremental update). 

122 

123 This loads existing ratings before the race and updates them. 

124 

125 Args: 

126 session: Database session 

127 race_id: Race ID to process 

128 engine: Existing rating engine (optional, will create if not provided) 

129 learn_adjustments: If True, learn adjustments from this race 

130 

131 Returns: 

132 Number of rating snapshots created 

133 """ 

134 if engine is None: 

135 engine = RatingEngine(db_session=session) 

136 

137 # Load race with relationships 

138 race = ( 

139 session.query(Race) 

140 .options(joinedload(Race.meeting)) 

141 .filter(Race.id == race_id) 

142 .one() 

143 ) 

144 

145 # Get starters 

146 starters = ( 

147 session.query(Starter) 

148 .filter(Starter.race_id == race_id) 

149 .options( 

150 joinedload(Starter.horse), 

151 joinedload(Starter.driver), 

152 joinedload(Starter.trainer), 

153 ) 

154 .all() 

155 ) 

156 

157 if not starters: 

158 logger.debug(f"No starters for race {race_id}") 

159 return 0 

160 

161 # Load current ratings for all entities involved 

162 for starter in starters: 

163 if starter.horse_id: 

164 _load_entity_rating(session, engine, "HORSE", starter.horse_id, race_id) 

165 if starter.driver_id: 

166 _load_entity_rating(session, engine, "DRIVER", starter.driver_id, race_id) 

167 if starter.trainer_id: 

168 _load_entity_rating(session, engine, "TRAINER", starter.trainer_id, race_id) 

169 

170 # Process race 

171 updates = engine.process_race(race, starters) 

172 

173 # Learn adjustments if enabled 

174 if learn_adjustments: 

175 engine.learn_adjustments_from_race(race, starters, use_global_only=True) 

176 

177 # Save snapshots 

178 snapshot_count = 0 

179 for update in updates: 

180 RatingSnapshotRepository.upsert( 

181 session, 

182 entity_type=update.entity_type, 

183 entity_id=update.entity_id, 

184 as_of_race_id=race_id, 

185 rating=update.new_rating, 

186 rd=update.rd, 

187 meta=update.meta, 

188 ) 

189 snapshot_count += 1 

190 

191 session.commit() 

192 

193 return snapshot_count 

194 

195 

196def _load_entity_rating( 

197 session: Session, 

198 engine: RatingEngine, 

199 entity_type: str, 

200 entity_id: int, 

201 before_race_id: int, 

202) -> None: 

203 """Load entity's latest rating into engine state. 

204 

205 Args: 

206 session: Database session 

207 engine: Rating engine 

208 entity_type: Type of entity 

209 entity_id: Entity ID 

210 before_race_id: Load rating before this race 

211 """ 

212 from packages.core.storage.models import EntityType 

213 

214 entity_enum = EntityType[entity_type] 

215 

216 snapshot = RatingSnapshotRepository.get_latest_rating( 

217 session, entity_enum, entity_id, before_race_id 

218 ) 

219 

220 if snapshot: 

221 engine.load_rating_state( 

222 entity_enum, 

223 entity_id, 

224 snapshot.rating, 

225 snapshot.rd, 

226 )