Live app: shiftfade.app
Shift Fade is a hockey analytics project focused on one core question:
How does player and line performance change as a shift gets longer?
The project combines NHL play-by-play ingestion, shift/stint reconstruction, expected goals modeling, and a web app for exploring player, line, and team-level fade patterns at 5v5.
Shift Fade is built around observed 5v5 on-ice results. Instead of only asking whether a player or line is good overall, the app also asks:
- Are they strong right after a line change?
- Do they hold their level as the shift gets older?
- Do they fade faster or slower than league average?
The web app includes:
- A league overview page that explains the metrics and shows league-wide distributions
- Player pages with observed shift-fade charts and individual shift logs
- A lines page for analyzing 3-forward combinations
- A team report page for deployment and roster breakdown analysis
A live version of the app is available at shiftfade.app.
- Python for ingestion, data processing, API logic, and analytics
- FastAPI for the web server
- A small React frontend bundled with
esbuild - Parquet files for processed season data
- R for the older RAPM workflow that still exists in the repo
hockey_analytics/
├── config/ # project settings and path helpers
├── dashboard/
│ ├── app.py # Streamlit dashboard entrypoint
│ └── web/
│ ├── server.py # FastAPI server for the web app
│ ├── app.jsx # main frontend source
│ ├── app.bundle.js # built frontend bundle
│ └── template.html # HTML shell and site-wide CSS
├── data/
│ ├── cache/ # cached payloads and helper artifacts
│ ├── processed/ # processed parquet outputs
│ └── raw/ # optional raw files
├── r/
│ └── rapm/ # older RAPM modeling scripts
├── src/
│ ├── api/ # API entrypoints
│ ├── features/ # matrix/feature generation
│ ├── ingestion/ # NHL ingestion and parsing
│ ├── models/ # model logic, readers, xG model
│ └── utils/ # utility helpers
├── tests/ # test suite
├── package.json # frontend build step
└── requirements.txt # Python dependencies
xGD/60 means expected goal differential per 60 minutes:
(expected goals for - expected goals against) / time on ice * 3600
Positive values mean a player, line, or team is driving better chance quality than it allows. Negative values mean the opposite.
The app compares performance early in a shift versus later in a shift.
Early xGD/60: results in the first 30 secondsMid xGD/60: results roughly in the 30 to 45 second windowLate xGD/60: results after 45 secondsDurability: late xGD/60 minus early xGD/60
More negative durability means a bigger drop-off as the shift gets longer.
To keep major charts and leaderboards from getting distorted by tiny samples, the app requires:
- at least
200merged player shifts - where the shift length exceeds
10seconds
Low-sample players can still be searched and opened, but their big charts show Not enough data.
At a high level, the project works like this:
- Pull NHL play-by-play and shift-related data
- Parse events into player on-ice stint rows
- Merge clock-adjacent player sub-stints into shifts
- Score shot events with an xG model
- Aggregate those stints into player, line, and team summaries
- Serve the processed data through the web app
The repo now includes a repo-native shot-based xG model in:
The model is designed around unblocked shot attempts and uses:
- shot location
- shot type
- score state
- rebound context
- rush context
- season effects
- rink effects
Its structure was inspired by the methodology in:
The xG model can be trained and saved to:
data/processed/xg_model_v1.json
Once trained, the parser will automatically use it when building new stint files.
python3 -m venv .venv
source .venv/bin/activatepip install --upgrade pip setuptools wheel
pip install -r requirements.txtnpm installThe main local app entrypoint is the FastAPI server in:
Run it with:
uvicorn dashboard.web.server:app --reload --port 8080Then open:
If you change:
dashboard/web/app.jsxdashboard/web/template.html
then rebuild the browser bundle with:
npm run build:webThe repo also still contains a Streamlit dashboard entrypoint:
Run it with:
streamlit run dashboard/app.pyProcessed season data lives in:
data/processed/stints_<season>.parquet
Example season codes:
202320242024202520252026
To ingest a season:
python -m src.ingestion.pipeline --season 20242025To force a rebuild:
rm -f data/processed/stints_20242025.parquet
rm -f data/cache/failed_games_20242025.txt
python -m src.ingestion.pipeline --season 20242025Train the xG model with one or more seasons:
python3 -m src.models.xg_model --seasons 20222023 20232024 20242025 --out data/processed/xg_model_v1.jsonAfter training, regenerate season stints so the app uses the trained model:
rm -f data/processed/stints_20232024.parquet
python -m src.ingestion.pipeline --season 20232024Repeat that for any seasons you want refreshed.
The repo still includes RAPM-era infrastructure. That flow looks like:
python -m src.features.export_matrix --season 20232024
Rscript r/rapm/rapm_model.R --season 20232024
python -m src.ingestion.resolve_names --season 20232024Some older pieces of the repo still expect RAPM parquet outputs, but the web app has largely shifted toward observed xGD/60 and fade-based summaries.
From the repo root:
PYTHONPATH=$(pwd) pytest -qIf your virtual environment is active, that is usually enough. If not:
PYTHONPATH=$(pwd) ./.venv/bin/python -m pytest -quvicorn dashboard.web.server:app --reload --port 8080npm run build:webrm -f data/cache/web_payload_*.jsonrm -f data/processed/stints_20252026.parquet
rm -f data/cache/failed_games_20252026.txt
python -m src.ingestion.pipeline --season 20252026You probably do not have the necessary parquet files in data/processed.
That season likely needs to be regenerated with the newer ingestion code so the stints parquet contains:
game_datehome_teamaway_team
The app uses cached season payloads in data/cache. If you change backend logic, clear:
rm -f data/cache/web_payload_*.jsonSome older seasons may rely on fallback team mapping if the stints parquet predates newer team metadata fields. Regenerating the season is the cleanest fix.
- This project is optimized around 5v5 analysis.
- The Lines page currently analyzes 3-forward trios, not full 5-man units.
- The app is designed to be readable for both hockey analytics users and more casual visitors.
The live project is available at shiftfade.app.
The xG-model approach was informed in part by: