# Elo Algorithm (Core Rating Calculation)

This document explains how TipSharks computes Elo ratings for horses, drivers, and trainers. It describes the full process, math, and every configuration knob that affects the core rating engine.

Source of truth: `packages/core/ratings/engine.py` and `packages/core/common/settings.py`.

## High-Level Flow

For each completed race:
1. Load or initialize rating states for each entity (horse/driver/trainer).
2. Build each starter's effective rating from entity ratings and learned adjustments.
3. Compute pairwise expected outcomes for every starter pair.
4. Compute pairwise actual outcomes from placings (ties and DNF handled by config).
5. Aggregate pairwise errors to get a rating delta per starter.
6. Apply deltas to horse, and proportionally to driver/trainer.
7. Clamp rating bounds and update rating deviation (RD) if enabled.
8. Optionally learn barrier/handicap adjustments from residual performance.

## Core Math

### Effective Rating

Each starter gets a single "effective" rating used for pairwise comparisons:

```
R_eff = R_horse + alpha * R_driver + beta * R_trainer
        + A_barrier + A_handicap
```

Where:
- `alpha` = driver weight (`DRIVER_WEIGHT_ALPHA`)
- `beta` = trainer weight (`TRAINER_WEIGHT_BETA`)
- `A_barrier`, `A_handicap` are learned condition adjustments

Driver/trainer terms are only included if those features are enabled and IDs exist.

### Expected Outcome (pairwise logistic)

For two starters i and j:

```
E_ij = 1 / (1 + exp(-(R_eff_i - R_eff_j) / C))
```

- `C` is the logistic scale factor (`ELO_SCALE_C`)

### Actual Outcome

```
S_ij = 1.0  if placing_i < placing_j
S_ij = 0.0  if placing_i > placing_j
S_ij = 0.5  if placing_i == placing_j and tie_handling = "half"
S_ij = 0.0  if placing_i == placing_j and tie_handling = "ordered"
skip         if placing_i == placing_j and tie_handling = "skip"
```

### Rating Delta for a Starter

Let `n` be the number of valid starters in the race (after filtering DNFs and min finishers).
Let `K_eff` be the effective K-factor for the starter.

```
delta_sum = sum over j != i of (S_ij - E_ij)
delta_R_i = K_eff * (delta_sum / normalizer)
```

The normalizer depends on `PAIRWISE_NORMALIZER`:
- `n_minus_1` -> `n - 1` (default)
- `n` -> `n`
- `comparisons` -> actual number of comparisons made (skips ties if configured)

### Applying the Delta to Entities

Horse update:
```
R_horse += delta_R_i
```

Driver update (if enabled):
```
R_driver += delta_R_i * alpha * DRIVER_K_SCALE
```

Trainer update (if enabled):
```
R_trainer += delta_R_i * beta * TRAINER_K_SCALE
```

Horse updates can be scaled by `HORSE_K_SCALE` before applying deltas.

### Rating Bounds

After each update:
```
R_new = clamp(R_old + delta, RATING_MIN, RATING_MAX)
```

Bounds are optional. If not set, ratings are unbounded.

## Rating Deviation (RD) and K-Factor Scaling

If `ENABLE_RD=true`, each entity tracks a rating deviation (RD) that represents uncertainty.

### RD Inflation for Inactivity

If the entity was inactive for `days_inactive`:
```
RD = min(RD + days_inactive * RD_INFLATION_PER_DAY, RD_MAX)
```

If `RD_INFLATION_CAP_DAYS` is set, `days_inactive` is capped.

### RD Decay for Participation

After a race:
```
RD = max(RD - max(RD_DECAY_PER_RACE, RD_DECAY_FLOOR), RD_MIN)
```

### Effective K-Factor

Base K is `ELO_K_BASE`. When RD is enabled, K is scaled by RD:

```
ratio = RD / INITIAL_RD
if RD_SCALING_MODE = "sqrt": ratio = sqrt(ratio)
if RD_SCALING_MODE = "none": ratio = 1.0
K_eff = ELO_K_BASE * ratio
```

`K_eff` is then clamped by `ELO_K_MIN` and `ELO_K_MAX` if set.

## Condition Adjustments (Barrier / Handicap)

Condition adjustments add or subtract rating points based on race conditions.

### Distance Bucketing

Adjustments are learned in buckets based on race distance:
- `DISTANCE_BUCKET_MODE = thresholds`: use `DISTANCE_BUCKETS` as cutoffs
- `DISTANCE_BUCKET_MODE = fixed`: use fixed size buckets of `DISTANCE_BUCKET_SIZE`

### Lookup

Barrier:
```
A_barrier = lookup(venue, start_type, distance_bucket, barrier)
```

Handicap:
```
A_handicap = lookup(venue, start_type, distance_bucket, handicap_m)
```

If `ADJ_GLOBAL_ONLY=true`, venue/start_type are ignored and only global keys are used.
If `ADJ_MIN_SAMPLES` is set, adjustments are ignored until they have enough samples.
If `ADJ_CLAMP_MIN/MAX` are set, adjustments are clamped before use.

### Learning Adjustments

If `ENABLE_ADJUSTMENTS=true`, adjustments are updated from race residuals:

1. Compute effective ratings with adjustments disabled (to avoid feedback).
2. For each starter, compute expected and actual pairwise outcomes.
3. Residual:
   ```
   residual = (actual_sum - expected_sum) / (n - 1)
   scaled = residual * ADJ_UPDATE_SCALE
   ```
4. Update the matching adjustment key by:
   ```
   A_new = A_old + ADJ_LEARNING_RATE * scaled
   ```

## Race Filtering and Placings

- Starters with `placing is None` or `did_not_finish` are filtered out by default.
- If `DNF_TREATED_AS_LAST=true`, DNFs are included as last-place finishers.
- If the number of valid starters is below `MIN_FINISHERS`, the race is skipped.
- Ties are handled based on `TIE_HANDLING`.

## Worked Example (Default Settings)

Scenario: 3-horse race, all ratings = 1500, no driver/trainer contributions, no adjustments, all finish, no ties.

Defaults:
- `ELO_K_BASE = 24`
- `ELO_SCALE_C = 400`
- `PAIRWISE_NORMALIZER = n_minus_1` (so `n - 1 = 2`)

Placings:
- Horse A: 1st
- Horse B: 2nd
- Horse C: 3rd

Pairwise expected outcomes (all equal ratings):
```
E_AB = E_AC = E_BA = E_BC = E_CA = E_CB = 0.5
```

Actual outcomes:
```
S_AB = 1, S_AC = 1
S_BA = 0, S_BC = 1
S_CA = 0, S_CB = 0
```

Delta sums:
```
delta_sum_A = (1 - 0.5) + (1 - 0.5) = 1.0
delta_sum_B = (0 - 0.5) + (1 - 0.5) = 0.0
delta_sum_C = (0 - 0.5) + (0 - 0.5) = -1.0
```

Apply normalization and K:
```
delta_R_A = 24 * (1.0 / 2) = +12
delta_R_B = 24 * (0.0 / 2) = 0
delta_R_C = 24 * (-1.0 / 2) = -12
```

New ratings:
```
R_A = 1512
R_B = 1500
R_C = 1488
```

Sum of deltas is zero, so there is no rating inflation.

## Configuration Knobs (All Rating Settings)

All settings live in `packages/core/common/settings.py` under `RatingSettings`.
Environment variables are the uppercase equivalents of the field names.

### Core Elo
- `ELO_SCALE_C`: logistic scale factor (C)
- `ELO_K_BASE`: base K-factor
- `ELO_K_MIN`: minimum K clamp
- `ELO_K_MAX`: maximum K clamp
- `PAIRWISE_NORMALIZER`: `n_minus_1`, `n`, or `comparisons`
- `INITIAL_RATING`: starting rating for new entities
- `RATING_MIN`: rating lower bound (optional)
- `RATING_MAX`: rating upper bound (optional)

### Rating Deviation (RD)
- `ENABLE_RD`: toggle RD tracking
- `INITIAL_RD`: starting RD for new entities
- `RD_MIN`: minimum RD
- `RD_MAX`: maximum RD
- `RD_DECAY_PER_RACE`: RD decrease per race
- `RD_DECAY_FLOOR`: minimum per-race decay
- `RD_INFLATION_PER_DAY`: RD increase per day inactive
- `RD_INFLATION_CAP_DAYS`: cap on inactive days
- `RD_SCALING_MODE`: `linear`, `sqrt`, or `none`

### Multi-Entity Weights and Scales
- `ENABLE_DRIVER`: include driver ratings
- `ENABLE_TRAINER`: include trainer ratings
- `DRIVER_WEIGHT_ALPHA`: driver weight in effective rating
- `TRAINER_WEIGHT_BETA`: trainer weight in effective rating
- `HORSE_K_SCALE`: multiplier for horse update size
- `DRIVER_K_SCALE`: multiplier for driver update size
- `TRAINER_K_SCALE`: multiplier for trainer update size

### Condition Adjustments
- `ENABLE_ADJUSTMENTS`: toggle learning and use of adjustments
- `ADJ_BARRIER_ENABLED`: enable barrier adjustments
- `ADJ_HANDICAP_ENABLED`: enable handicap adjustments
- `ADJ_LEARNING_RATE`: adjustment learning rate
- `ADJ_UPDATE_SCALE`: scale applied to residual before learning
- `ADJ_MIN_SAMPLES`: minimum samples before applying adjustment
- `ADJ_CLAMP_MIN`: clamp min for adjustment values
- `ADJ_CLAMP_MAX`: clamp max for adjustment values
- `ADJ_GLOBAL_ONLY`: ignore venue/start_type in keys
- `DISTANCE_BUCKETS`: thresholds for distance buckets
- `DISTANCE_BUCKET_MODE`: `thresholds` or `fixed`
- `DISTANCE_BUCKET_SIZE`: fixed bucket size if mode is `fixed`

### Race Handling
- `MIN_FINISHERS`: minimum starters to rate a race
- `DNF_TREATED_AS_LAST`: count DNF as last place
- `TIE_HANDLING`: `ordered`, `half`, or `skip`

## Where to Look in Code

- Core rating math: `packages/core/ratings/engine.py`
- Settings and defaults: `packages/core/common/settings.py`
- Mathematical overview: `docs/rating_math.md`
