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
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-08 08:37 +1200
1"""Recompute ratings deterministically from stored race results."""
3from datetime import date
5from sqlalchemy.orm import Session, joinedload
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
12logger = get_logger(__name__)
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.
24 This is deterministic: same inputs will always produce same outputs.
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
33 Returns:
34 Number of rating snapshots created
35 """
36 logger.info(f"Recomputing ratings from {date_from} to {date_to}")
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")
45 # Initialize rating engine with database session
46 engine = RatingEngine(db_session=session)
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")
52 snapshot_count = 0
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 )
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 )
72 if not starters:
73 logger.debug(f"Skipping race {race.id} - no starters")
74 continue
76 # Load race.meeting for venue access (for effective rating computation)
77 if not race.meeting:
78 session.refresh(race, ["meeting"])
80 # Process race and get updates
81 updates = engine.process_race(race, starters)
83 # Learn adjustments if enabled
84 if learn_adjustments:
85 engine.learn_adjustments_from_race(race, starters, use_global_only=True)
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
100 # Commit periodically
101 if idx % 50 == 0:
102 session.commit()
104 # Final commit
105 session.commit()
107 logger.info(
108 f"Recompute complete: processed {len(races)} races, "
109 f"created {snapshot_count} rating snapshots"
110 )
112 return snapshot_count
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).
123 This loads existing ratings before the race and updates them.
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
131 Returns:
132 Number of rating snapshots created
133 """
134 if engine is None:
135 engine = RatingEngine(db_session=session)
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 )
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 )
157 if not starters:
158 logger.debug(f"No starters for race {race_id}")
159 return 0
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)
170 # Process race
171 updates = engine.process_race(race, starters)
173 # Learn adjustments if enabled
174 if learn_adjustments:
175 engine.learn_adjustments_from_race(race, starters, use_global_only=True)
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
191 session.commit()
193 return snapshot_count
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.
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
214 entity_enum = EntityType[entity_type]
216 snapshot = RatingSnapshotRepository.get_latest_rating(
217 session, entity_enum, entity_id, before_race_id
218 )
220 if snapshot:
221 engine.load_rating_state(
222 entity_enum,
223 entity_id,
224 snapshot.rating,
225 snapshot.rd,
226 )