Fair-Value Model — How It Works & How We Trained It
What it does (one line)
Given a live chess position + both clocks + both player ratings, it outputs a calibrated probability that each player wins the *encounter* — the number we compare against the Kalshi market mid to find mispricings to trade.
How it works (inference pipeline)
live board + clocks + ratings
|
v
1. STOCKFISH (pinned: depth 12, multipv 2, 1 thread)
-> cp centipawn eval, White POV
-> best_gap eval(best move) - eval(2nd) = "only-move-ness" / sharpness
|
v
2. FEATURES (the human-practical "asterisk")
cp, best_gap, log(white_clock), log(black_clock), white_to_move,
elo_diff, low-time flags, ply, total_material (phase), queens_on,
+ interactions: cp x time-trouble, cp x rating
|
v
3. LOGISTIC MODEL -> P(White win), P(Draw), P(Black win) [classical game]
|
v
4. ENCOUNTER COMBINE (Norway: a drawn classical -> Armageddon ~ coin flip)
P(player wins encounter) = P(their win) + P(draw) x 0.5
Inference is a microsecond matrix multiply once Stockfish has evaluated the position — the engine eval is the only real cost.
Why not just use the engine's eval?
Raw Stockfish assumes *best play by both sides* — it doesn't know a human with 30 seconds left usually collapses. Our model learns the human-practical mapping from real game outcomes. Concretely: a roughly equal position (eval ~+0.3) but White down to 30s on the clock → the model drops White's win-prob from 52% to 13%. Raw Stockfish still calls it equal. *That gap is the edge.*
In fact, on our data raw Stockfish WDL is worse than just guessing the base rate for elite humans (skill −1.0) — it's overconfident about converting edges that grandmasters routinely draw. So a learned, human-calibrated layer isn't optional; it's the whole point.
How we trained it
Data. ~1,503 games / 20,002 positions from 2026 Lichess broadcasts that include clock data (Norway Chess + several opens, spanning a wide rating range so the model can learn the rating effect). Classical only — Armageddon excluded (different time control). Each sampled position records the Stockfish features + both clocks + both ratings + the final game result as the label (White POV).
Train/live parity. The Stockfish config is pinned and identical for training and live inference (same version, depth, multipv, threads). Training on deep evals but serving shallow ones is the most common way these models silently break — we avoid it by construction.
Model choice. Multinomial logistic regression. GBT gets a marginally better log-loss at this data size but is miscalibrated — and our band strategy needs accurate *probabilities*, not just discrimination — so we ship the verified-calibrated logistic. (Isotonic calibration on top was tried and *hurt* — overfit.)
Validation — blind, by game:
Honest limitations
The files
engine_eval.py (pinned Stockfish) · features.py (feature vector, shared by train + live = parity) · build_dataset_big.py (data builder) · model.py (training + validation) · fair_value.py (live inference) · calibration_test.py (the blind reliability test) · winprob_model.pkl (the saved model).