Skip to the content.

A longer note on why this tool exists, how the numbers are made, and what the backtests showed. For install and commands, see the repository README.

History

The project started as a personal tool for myself (Stanislav Litvinov, SK Niš, Olympic Bow, Seniori). Before a competition the only public picture of the field is a registration table plus scattered result pages. The first version was a single script for Indoor 18m tournament type: scrape who is entered, weight recent scores, simulate places, print three goals.

It grew into this package: indoor and outdoor rounds, club and mixed-team forecasts, HTTP caching, and a backtest against finished official SSS events. The model stayed deliberately small so a predicted 445 is something you can argue with, not a score from a trained net.

The problem

A Serbian tournament weekend answers three practical questions:

A naive average of everything on an archer’s profile fails in several ways. Indoor 18m (max 600) and outdoor 70m (max 720) cannot be mixed. A walk-off of 161 or 322 is not a current level. Two no-shows change everyone’s place. Someone shooting the round for the first time has no history to average. Recency matters: last month should count more than last year.

Coaches and archers still do this in their heads, badly, the night before KV.

The solution

Given a registration URL, style, category, and round:

  1. Scrape who is entered.
  2. Load each archer’s results for the chosen seasons, only that round and category, dropping incomplete scores.
  3. Predict a qualification mean μ and spread σ.
  4. Simulate the field 2,500 times (plus a small elimination place shift from KV vs EL).
  5. Print a table: expected place (median and middle 50%), score, distance to the field median, confidence, and goals for --me.
  6. Optionally validate: pretend a finished event has not been shot yet, compare to actual KV, report MAE and bias.

Walk-offs, DNS, and archers with no history are shown but excluded from error metrics. They are not treated as 0.

Here is a live forecast of an outdoor Seniori field. Place(P50) is the typical finish; Place(P25–P75) is the ordinary spread of simulated days. Goals and the short verdict appear for --me.

Forecast table for Olympic Bow Seniori, outdoor 70m

How it works

Data

Source Use
/tournaments/{id}/registration/all Field: name, club, style, category
/archers/info/{id}/?sezona= Scores, dates, personal best
/tournaments/{id}/archers Actual KV scores; KV vs EL place for the elimination shift
/tournaments/?sezona= Official SSS list (Zvanični turniri SSS only) for --events backtests

--round indoor18 keeps Indoor 18m (max 600). --round outdoor70 keeps Outdoor 70/50m (max 720). Scores below 45% of max (270 indoor / 324 outdoor) are treated as incomplete. In validation, a result is also incomplete if it falls 100 points (or 4σ) below the prediction — a walk-off that still cleared 45%.

Qualification score

Last 24 matching scores, exponential half-life of 120 days:

weight = 0.5 ** (days_ago / 120)
base   = weighted average of scores

Then the named adjustments in config.py (ADJ):

Weight Default Effect
recent_delta 0.45 Pull toward the last score: + 0.45 × (last − base)
pb_delta 0.10 Small lift toward personal best, only if PB is above base
trend_points 0.10 Linear trend × 30 days
age_penalty 0.35 0.35 × \|age − 28\| (peak age 28)
uncertainty 1.0 Penalty for a thin sample: 4 if n < 3, else 2 / √n
cup 0.0 Reserved for a JRLT / ranking-cup prior; unused

So:

μ = base
  + 0.45 × (last − base)
  + 0.10 × max(0, PB − base)
  + 0.10 × trend_30d
  − age_penalty
  − uncertainty_penalty

σ is the sample standard deviation, at least 3, and at least 12 when there are fewer than three scores (so “safe” can sit below a two-start worst score on purpose).

Goals for --me:

Place

Each Monte-Carlo iteration draws every archer’s qualification from N(μ, σ), ranks the field, then shifts place using elimination history:

place = qualification_place − 0.35 × mean(KV_place − EL_place)

A positive KV−EL (you climb in matches) improves predicted place. Eliminations never change the score prediction; they only move place.

Place(P50) is the median of those simulated places. Place(P25–P75) is the middle half of days. # is just sort order after that (ties broken by predicted score). Percentiles (Top 25% / 33% / 50%, bottom-25% risk) exist because fields are 12 people one week and 30 the next — absolute “top 8” would lie.

Club team forecast: sum of the top three same-category scores per club. Mixed team: best man + best woman from the same club (the counterpart category is fetched automatically).

Club and mixed-team forecasts

Backtest

--validate on one URL, or --validate --events N on the last N finished official SSS events of that round:

One event looks like this — predicted vs actual, with no-history and incomplete rounds left out of MAE:

Validation table comparing predicted and actual qualification

Across a season, the summary is one line per official SSS event:

Backtest summary for outdoor Seniori: MAE score, bias, MAE place

On official outdoor 70m, Seniori landed around MAE score 21, bias about −7, place 0.7. Seniorke were noisier (MAE score ~31, place ~1.3). Indoor place error is larger because fields are packed. Bias was not stable enough across men/women and indoor/outdoor to retune ADJ.

Who might use it

It is a forecasting aid. It does not know injuries, sight settings, or who will walk off. First outdoor (or a long break) is reported as no history, not as a score of 0.

Future ideas