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.htmlfor the full table grid.
- 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 headliningdashboard.htmlplus per-script CSVs and feature-importance plots. - Findings (
FINDINGS.md): technical narrative of the work.
# 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/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/eprochassonand unzip the.csv.gzfiles intodata/raw/eprochasson/data/. - lauchunlok/HKJC-Scraping (GitHub) — Selenium scraper for 2021+.
Run
scrape_race_result.pyandscrape_sectional_time.pyto fill the gap. ~6 hours per year.
FINDINGS.mddocuments the three real engineering issues we hit (brokenrace_idin 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.
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).
| 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.
| 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%.
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".
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.
| 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 | 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.
| 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.
For evaluation purposes. No warranty.