Skip to content

catowabisabi/horse-racing-model-training

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

HKJC Horse Racing Prediction

A complete pipeline that trains gradient-boosted models on Hong Kong Jockey Club race data and simulates betting strategies on a leakage-controlled validation set.

Headline result. A LightGBM model trained on 2014–2016 race data achieves +7.9% ROI on the 2017 quinella (連贏) top-2 box bet and +2.6% ROI out-of-sample on 2018 H1 — the only model/strategy pair in our test matrix that is positive on both periods. Strike rate of the model's top-1 pick in the pre-race window is 27%, vs 8% random and 22% in the post-close window, indicating genuine pre-race alpha.

Open reports/dashboard.html for the full table grid.

What's Here

  • Code (src/): data loaders, feature engineering, two model trainers (LightGBM + XGBoost), an ensemble blender, a Kelly betting simulator, a Benter-style blend simulator, a quinella / exotic bet simulator, an OOS validator, and an HTML report builder.
  • Tests (tests/): 41 unit tests on the Kelly betting math, DB schema, and live-odds merge logic.
  • Processed data (data/processed/*.parquet): the modelling table and the morning/late odds lookups. 6 parquet files, ~25 MB.
  • Trained models (models/*.txt / *.json): the production artifacts for the LightGBM, XGBoost, JRA, and morning-odds LightGBM regressor.
  • Reports (reports/): the headlining dashboard.html plus per-script CSVs and feature-importance plots.
  • Findings (FINDINGS.md): technical narrative of the work.

Quickstart

# 1. Install deps
pip install lightgbm xgboost pandas scikit-learn pyarrow pytest

# 2. Re-build everything from raw data (optional — processed/ and
#    models/ are committed so you can skip to step 3).
cd src
python ingest_eprochasson.py        # builds data/hkjc.db
python build_features.py            # builds features_2008_2018.parquet
python train_baseline.py            # LightGBM with-odds + no-odds
python train_xgboost.py --mode both # XGBoost with-odds + no-odds
python make_ensemble_predictions.py # 50/50 average
python run_kelly_simulation.py      # full Kelly matrix
python run_exotic_bet_simulation.py # quinella, QPL, tierce, etc.
python analyze_quinella_filters.py   # filter grid
python validate_quinella_oos.py --pred val_predictions_no_odds.csv `
        --start 2018-01-01 --end 2018-07-01 --label 2018_h1_lgb_no_odds
python build_html_report.py         # writes reports/dashboard.html

# 3. Run tests
pytest tests/

Data

The pipeline is designed to work with two data sources:

  • eprochasson/horserace_data (GitHub) — public mirror of HKJC 2008-2018. Clone with git clone https://github.com/eprochasson/horserace_data.git data/raw/eprochasson and unzip the .csv.gz files into data/raw/eprochasson/data/.
  • lauchunlok/HKJC-Scraping (GitHub) — Selenium scraper for 2021+. Run scrape_race_result.py and scrape_sectional_time.py to fill the gap. ~6 hours per year.

Key Decisions Worth Reading

  • FINDINGS.md documents the three real engineering issues we hit (broken race_id in the source data, time-aware rolling features, one-hot dtype quirks) and how we fixed them.
  • The Benter-blend simulator is in src/run_benter_blend_simulation.py.

Results

All numbers below are on a 1-unit flat stake, dividends taken from all_dividends.csv.gz. Train: 2014–2016. Validation: 2017 (818 races). Out-of-sample: 2018 H1 (452 quinella bets).

Quinella top-2 box (連贏) — the strategy that worked

Model Year Bets Wins Strike ROI Notes
lgb_no_odds 2017 val 817 69 8.4% +7.9% Pure fundamental, no public odds blend
lgb_no_odds 2018 H1 OOS 452 40 8.8% +2.6% Same model, retrained on 2014-2017
xgb_no_odds 2017 val 817 66 8.1% -6.5%
ensemble_no_odds 2017 val 817 66 8.1% -7.3%
lgb_with_odds 2017 val 817 103 12.6% -10.0% Higher strike but lower payouts
xgb_with_odds 2017 val 817 97 11.9% -13.2%

The 50/50 LightGBM + XGBoost blend does not outperform the LightGBM-only model on quinella. The two algorithms learn similar patterns and averaging mostly cancels out wins. The interesting finding is that a model trained without odds as a feature wins quinella — the public odds crowd already price favourites accurately, and beating the crowd means leaning into the model's intrinsic view of the horse.

Quinella filters (2017 val)

Filter Bets Wins Strike ROI
All 817 69 8.4% +7.9%
max_odds ≥ 12 (cold quinella) 240 15 6.3% +62.6%
max_morning_odds ≥ 12 302 23 7.6% +37.7%
max_odds_rank ≥ 4 467 29 6.2% +33.5%
max_odds 0–5 (hot quinella) 152 29 19.1% +6.3%

Cold quinella carries the edge. Filtering to "model top-2, but at least one is odds ≥ 12" lifts ROI from +7.9% to +62.6%.

Quinella OOS (2018 H1)

Same lgb_no_odds quinella strategy on a fresh 2018 H1 window:

Filter Bets Wins Strike ROI
All 452 40 8.8% +2.6%
max_final_odds ≥ 8 218 10 4.6% +4.2%
max_final_odds ≥ 12 119 4 3.4% -1.2%
max_morning_odds ≥ 8 265 18 6.8% -1.6%

The 2018 H1 OOS result is positive on the all-bets row but the cold filter (max_odds ≥ 12) breaks down with only 4 wins out of 119 bets. This is sample-size territory: 452 quinella bets is at the lower edge of statistical significance, and the +2.6% number should be read as "signal consistent with 2017" rather than "confirmed edge".

Benter blend (1997-style)

blend_score = fund_prob^w × public_prob^(1-w), w ∈ [0, 1].

Best Win top-1 ROI Best Quinella top-2 ROI
Best model xgb_no_odds, w=0.4 (strike 30.4%, ROI -15.2%) lgb_no_odds, w=1.0 (strike 8.4%, ROI +7.9%)
Take-away A 40/60 model/public blend helps Win top-1 Pure fundamental wins quinella

For Win top-1, mixing in public odds (~40%) helps because public odds filter out impossible longshots, lifting strike from 23% to 30%. For Quinella top-2, the public odds crowd already prices the favourite-vs-favourite combinations accurately; adding public information just dilutes the model's edge.

Other strategies (2017 val, summary)

Strategy Best model Best ROI
Place top-1 xgb_with_odds -15.2%
Place top-2 each ensemble_no_odds -11.3%
Place top-3 each xgb_no_odds -13.5%
QPL top-2 xgb_no_odds -3.1%
QPL top-3 box xgb_no_odds -14.2%
Tierce top-3 exact xgb_no_odds -3.4%
Quartet top-4 exact (all models) -62% to -100%
Quinella top-2 box lgb_no_odds +7.9%

The strategy space is wide; quinella is the only positive-ROI pool under flat-stake assumptions.

Model card (in-sample 2017 val)

Model Library AUC Top-1 hit Top-3 hit Notes
LightGBM with-odds lightgbm 4.6 0.7833 28.5% 61.1% Trained on 2014-2016, val 2017
LightGBM no-odds lightgbm 4.6 0.6782 22.9% 49.3% Same features minus winning_odds, implied_prob, etc.
XGBoost with-odds xgboost 2.x ~0.78 ~28% ~60% 50/50 with LightGBM is the ensemble
XGBoost no-odds xgboost 2.x ~0.68 ~23% ~49%
JRA LightGBM no-odds lightgbm 4.6 Cross-market sanity check on Kaggle JRA data

The "no-odds" models cap out at AUC 0.68 because they can only learn from intrinsic horse / jockey / trainer signals, not from the public market. But they're the ones with positive ROI on quinella, because the quinella pool is mispriced relative to the model.

Kelly criterion (per-stake ROI, 2017 val)

Scenario Strategy n_bets strike ROI/stake
win_odds (final payout) flat 1% 228 21.9% -26.8%
win_odds (final payout) kelly quarter + 5pp filter 66 21.2% -20.9%
real_morning_odds (clean) flat 1% 456 27.4% +119%
real_morning_odds (clean) kelly quarter + 10pp filter 297 28.0% +140%

The real_morning_odds row uses the earliest non-999 capture from live_odds.csv.gz — i.e. the price a bettor actually saw before the race went off. The strike rate jump from 22% (final odds) to 27% (real morning) is the cleanest evidence that the model has genuine pre-race edge; the take-home ROI numbers are inflated by Kelly compounding and should be read directionally, not literally.

License

For evaluation purposes. No warranty.

About

LightGBM/XGBoost ensemble + Kelly criterion for HKJC horse racing. 2017 quinella +7.9% ROI, 2018 H1 OOS +2.6% ROI.

Topics

Resources

License

Stars

2 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors