← Notebook

Experiments · Rejected

Schedule load, travel and per-team home court

Games in the last four or six nights, travel distance, time zones and team-specific home-court strength were each tested and did not improve accuracy.

Hypothesis

Fatigue and travel are a staple of NBA analysis: the fourth game in six nights, a West Coast trip, a team flying in from three time zones away. Some arenas, Denver and Utah in particular, are also said to give a bigger home edge than others. If any of that is real and the model does not already capture it, adding it should help.

What the model already knows

Rest is already in the model, as days since each team’s last game (capped at 5) plus a back-to-back flag:

data/ingestion/build_features.pylines 292–327
def add_rest_days(df):
    """Calculate rest days for each team before each game."""
    print("  Computing rest days...")

    # Same rule as the live get_rest_days (model/predict.py): days since the
    # team's previous final game, home or away, from the full schedule (not just
    # rows in df), capped at REST_CAP; 3 when there is no previous game.
    df = df.sort_values('game_date').copy()
    df['game_date'] = pd.to_datetime(df['game_date'])

    sched = pd.read_sql(text("""
        SELECT game_date, home_team_id AS team_id FROM games WHERE is_final = TRUE
        UNION
        SELECT game_date, away_team_id AS team_id FROM games WHERE is_final = TRUE
    """), engine)
    sched['game_date'] = pd.to_datetime(sched['game_date'])
    dates_by_team = {t: np.sort(g['game_date'].unique())
                     for t, g in sched.groupby('team_id')}

    def rest_for(team_id, game_date):
        dates = dates_by_team.get(team_id)
        if dates is None:
            return 3
        i = np.searchsorted(dates, np.datetime64(game_date), side='left')
        if i == 0:
            return 3
        days = int((np.datetime64(game_date) - dates[i - 1]) / np.timedelta64(1, 'D'))
        return max(0, min(REST_CAP, days))

    df['home_rest_days'] = [rest_for(t, d) for t, d in zip(df['home_team_id'], df['game_date'])]
    df['away_rest_days'] = [rest_for(t, d) for t, d in zip(df['away_team_id'], df['game_date'])]
    df['rest_advantage']    = df['home_rest_days'] - df['away_rest_days']
    df['home_back_to_back'] = (df['home_rest_days'] == 1).astype(int)
    df['away_back_to_back'] = (df['away_rest_days'] == 1).astype(int)

    return df

Home advantage is already in two places: the 70-point home bonus inside the Elo gap, and the model itself, which learns home and away scores separately.

What was tested

Each candidate was added on its own to the lean feature set and run through the same paired walk-forward cross-validation (three seeds) used for every feature decision:

CandidateDefinition
Schedule loadGames played in the previous 4 nights, and in the previous 6 nights
TravelMiles between the previous game’s arena and tonight’s, and time zones crossed
Per-team home courtEach team’s own historical home-minus-road margin, in place of one league-wide edge

Finding

None improved margin error or winners picked. The decision is recorded next to the feature list, so it is not re-tested by accident:

model/train.pylines 19–24
# Lean set (2026-11-05): paired walk-forward CV over 3 seeds, vs the previous 42 features:
# margin MAE 12.05 -> 11.65, winners 63.8% -> 66.1%, better on every seed. Shooting
# splits, FT/oreb/tov/efg, location splits, scoring std, matchup products and in-series
# form added nothing once Elo + season blend were in (they mostly re-describe team
# strength with more noise). Schedule load (games in last 4/6 days), travel distance /
# time zones and per-team home-court strength were tested and added nothing either.

The most likely reason is overlap. Heavy schedule stretches usually include a back-to-back, which the model already sees. Per-team home strength is a small number estimated from a few hundred games, so it is mostly noise.

Still open

A separate question is whether the betting market overreacts to these spots even though they do not help the forecast. If the market adjusts a line two points for a fourth game in six nights and the true effect is close to zero, that would show up as a disagreement worth tracking. Testing it needs historical odds, which the project does not have yet.