Data pipeline
28 free Claude Code skills for NHL analytics, betting models, and hockey research. Works with PuckAPI MCP server for live data.
npx -y skills add PuckAPI/claude-sports-analytics --skill data-pipelineAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Builds automated sports analytics pipelines -- daily data pulls, scheduled prediction generation, model versioning, prediction tracking, and drift alerting. Use when user asks how to automate their model, how to run predictions every morning, how to set up a cron job for sports data, how to track model performance over time, or how to version their model. Do not use for one-time data pulls -- use game-lookup or puckapi-tool directly. Do not use for building the model itself -- see model-building. Do not use for backtesting historical performance -- see backtesting.
SKILL.md
18.4 KB, as published. Nobody here has run it
Data Pipeline
Default data tool: PuckAPI (
puckapi-tool). Pipeline automation calls the same MCP endpoints used interactively. Daily NHL data pull:get_games(5) +get_odds(10/game) = ~125 credits per run (avg 12 games). Full season backfill: ~12,300 credits for odds. For building the prediction model that runs in the pipeline, seemodel-buildingfirst.
You are an expert in sports analytics automation. Your goal is to bridge the gap between "I built a model" and "my model runs every morning and I check results over coffee." This is where a school project becomes a system.
When to Use
- "How do I automate my daily NHL picks?"
- "I want my model to run at 6 AM every morning"
- "How do I set up a GitHub Action for sports data?"
- "How do I track which model version made which prediction?"
- "When should I retrain my model?"
- "How do I detect if my model is drifting?"
- "I want to chain play-by-play + odds + injury data in one pipeline"
When NOT to Use
- One-time data pulls -- use
puckapi-toolorgame-lookupdirectly - Building the prediction model itself -- see
model-building - Validating a model's historical performance -- see
backtesting - Generating a one-time picks card -- see
daily-card
Commands Available
This skill does not call data tools directly. It generates pipeline code and configuration.
| Output Type | What It Produces |
|---|---|
| GitHub Actions YAML | Daily cron job for data pull and prediction generation |
| Python script | Data fetch + prediction generation skeleton |
| SQLite schema | Prediction tracking and actuals logging |
| Drift alert script | Detects accuracy drop or edge compression |
| Retrain trigger | Condition-based model refresh logic |
Commands That Do NOT Exist
| Not Available | Use Instead |
|---|---|
schedule_pipeline | Use GitHub Actions cron or system cron via the YAML below |
auto_retrain | Implement retrain trigger logic manually (see Retrain Triggers section) |
get_injuries | Injury data not available via puckapi-tool; integrate a separate source |
stream_live_data | MCP is pull-only; schedule frequent polls instead of streaming |
Initial Assessment
Before building the pipeline, understand:
- What does the pipeline need to produce? Predictions only, or predictions + picks + reports?
- How often does it run? Daily (most common), before each game window, or event-driven?
- Where do predictions get stored? SQLite (local), Postgres, Google Sheets, or Notion?
- Does the model already exist? If not, build it first via
model-building.
How It Works
Step 1: Define the pipeline shape
The standard sports analytics pipeline has four stages:
[Schedule] -> [Data Pull] -> [Prediction] -> [Storage + Alert]
| | | |
Cron job puckapi- Your model SQLite /
6 AM daily tool API .predict() Sheets / API
Step 2: Set up the GitHub Actions cron
The daily NHL pipeline:
# .github/workflows/daily-nhl-pipeline.yml
name: Daily NHL Pipeline
on:
schedule:
- cron: '0 11 * * *' # 6 AM ET = 11:00 UTC
workflow_dispatch: # Manual trigger for testing
jobs:
run-pipeline:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Pull today's games
env:
PUCKAPI_API_KEY: ${{ secrets.PUCKAPI_API_KEY }}
run: python scripts/pull_games.py --date today
- name: Pull odds
env:
PUCKAPI_API_KEY: ${{ secrets.PUCKAPI_API_KEY }}
run: python scripts/pull_odds.py --date today
- name: Generate predictions
run: python scripts/generate_predictions.py --date today
- name: Log predictions to SQLite
run: python scripts/log_predictions.py --date today
- name: Check for drift alert
run: python scripts/drift_check.py --window 14
- name: Commit results
run: |
git config --global user.email "[email protected]"
git config --global user.name "Pipeline Bot"
git add data/ predictions/
git commit -m "Daily pipeline: $(date +%Y-%m-%d)" || echo "Nothing to commit"
git push
Time to schedule: NHL games are typically announced by noon ET. Run at 6 AM ET to catch morning lines, then again at noon to refresh odds before evening games if needed.
Step 3: Data pull script skeleton
# scripts/pull_games.py
#
# Data pulls use the PuckAPI MCP server, NOT a REST API.
# The MCP server must be connected to Claude via:
# claude mcp add puckapi -- npx -y @anthropic-ai/sdk puckapi
# (or however the MCP server is configured in your project's .mcp.json)
#
# This script is meant to be run by Claude with the MCP server connected.
# Claude calls the MCP tools (get_games, get_odds, etc.) directly.
# The script below handles saving the results Claude returns.
import argparse
import json
import os
from datetime import date
def save_games(games: list, target_date: str):
"""Save game data returned by Claude's get_games MCP tool call."""
output_path = f"data/games/{target_date}.json"
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w") as f:
json.dump({
"date": target_date,
"pulled_at": date.today().isoformat(),
"games": games
}, f, indent=2)
print(f"Saved {len(games)} games for {target_date}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--date", default="today")
args = parser.parse_args()
target = date.today().isoformat() if args.date == "today" else args.date
# In practice, Claude calls get_games(date_from=target, date_to=target)
# via MCP and passes the result to save_games().
print(f"Run this via Claude with MCP connected:")
print(f" 1. Claude calls get_games(date_from='{target}', date_to='{target}')")
print(f" 2. Results are saved to data/games/{target}.json")
How the MCP data pull works:
There is no REST API endpoint. PuckAPI is an MCP server. To pull data:
- Connect the MCP server to Claude Code: ensure
.mcp.jsonhas the puckapi server configured, or runclaude mcp add puckapiwith the appropriate command. - Claude calls the MCP tools directly --
get_games(date_from="2026-01-15", date_to="2026-01-15")returns today's games. No HTTP requests, no API keys in headers. - Save the results to local JSON/SQLite using a Python script that receives Claude's tool output.
For automated pipelines (GitHub Actions, cron), use claude -p "Pull today's games using get_games and save to data/games/" to invoke Claude headlessly with the MCP server attached.
# scripts/generate_predictions.py
import argparse
import json
import pickle
from datetime import date
def generate_predictions(target_date: str):
"""Load model and generate predictions for today's games."""
# Load the versioned model
model_version = get_active_model_version()
with open(f"models/{model_version}/model.pkl", "rb") as f:
model = pickle.load(f)
# Load today's games + features
with open(f"data/games/{target_date}.json") as f:
games_data = json.load(f)
with open(f"data/odds/{target_date}.json") as f:
odds_data = json.load(f)
predictions = []
for game in games_data["games"]:
features = build_features(game, odds_data)
home_win_prob = model.predict_proba([features])[0][1]
predictions.append({
"game_id": game["id"],
"date": target_date,
"home_team": game["home_team"],
"away_team": game["away_team"],
"home_win_prob": round(home_win_prob, 4),
"model_version": model_version,
"generated_at": date.today().isoformat()
})
output_path = f"predictions/{target_date}.json"
with open(output_path, "w") as f:
json.dump(predictions, f, indent=2)
print(f"Generated {len(predictions)} predictions with model {model_version}")
return predictions
def get_active_model_version() -> str:
"""Read the active model version from the registry."""
with open("models/registry.json") as f:
registry = json.load(f)
return registry["active"]
def build_features(game: dict, odds_data: dict) -> list:
"""Build feature vector for one game. Implement your feature engineering here."""
# Replace with your actual feature engineering
raise NotImplementedError("Implement build_features with your model's inputs")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--date", default="today")
args = parser.parse_args()
from datetime import date as d
target = d.today().isoformat() if args.date == "today" else args.date
generate_predictions(target)
Step 4: SQLite prediction tracking schema
-- predictions.db schema
CREATE TABLE IF NOT EXISTS predictions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id TEXT NOT NULL,
game_date DATE NOT NULL,
home_team TEXT NOT NULL,
away_team TEXT NOT NULL,
home_win_prob REAL NOT NULL, -- Model probability
model_version TEXT NOT NULL, -- Version that made this prediction
generated_at TIMESTAMP NOT NULL,
opening_odds REAL, -- Market odds at prediction time
closing_odds REAL, -- Line at game time (fill later)
actual_result INTEGER, -- 1 = home win, 0 = away win (fill after game)
notes TEXT
);
CREATE TABLE IF NOT EXISTS model_versions (
version TEXT PRIMARY KEY,
trained_at DATE NOT NULL,
training_games INTEGER NOT NULL,
val_accuracy REAL NOT NULL,
val_auc REAL NOT NULL,
feature_set TEXT NOT NULL, -- JSON list of features
is_active INTEGER DEFAULT 0,
notes TEXT
);
CREATE TABLE IF NOT EXISTS drift_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
check_date DATE NOT NULL,
window_days INTEGER NOT NULL,
games_in_window INTEGER NOT NULL,
accuracy REAL NOT NULL,
expected_acc REAL NOT NULL,
drift_flag INTEGER DEFAULT 0, -- 1 if alert triggered
notes TEXT
);
-- Useful query: rolling 14-day accuracy
SELECT
game_date,
AVG(CASE WHEN actual_result = ROUND(home_win_prob) THEN 1.0 ELSE 0.0 END)
OVER (ORDER BY game_date ROWS BETWEEN 13 PRECEDING AND CURRENT ROW) AS rolling_14d_acc
FROM predictions
WHERE actual_result IS NOT NULL
ORDER BY game_date;
Step 5: Model versioning
# models/registry.json -- update manually or via script after training
{
"active": "v2.1.0",
"versions": [
{
"version": "v1.0.0",
"trained": "2025-10-01",
"val_accuracy": 0.563,
"features": ["cf_pct", "pdo", "rest_days"],
"retired": "2025-12-15"
},
{
"version": "v2.0.0",
"trained": "2025-12-15",
"val_accuracy": 0.571,
"features": ["cf_pct", "xgf_pct", "pdo", "rest_days", "h2h_last5"],
"retired": "2026-01-20"
},
{
"version": "v2.1.0",
"trained": "2026-01-20",
"val_accuracy": 0.579,
"features": ["cf_pct", "xgf_pct", "pdo", "rest_days", "h2h_last5", "back_to_back"],
"is_active": true
}
]
}
Versioning rule: Never overwrite a model file. Each train_model.py run outputs a new version directory: models/v2.2.0/model.pkl. Update registry.json to promote it to active.
Step 6: Retrain triggers
When to retrain (pick the condition that fits your workflow):
| Condition | Trigger | Action |
|---|---|---|
| Calendar-based | Monthly, on the 1st | Run train_model.py with last 2 seasons of data |
| Game count | Every 50 new games | Rolling window retrain |
| Accuracy drop | 14-day accuracy falls below (expected - 3pp) | Urgent retrain + review feature importance |
| New season | October 1st | Full retrain on completed season |
| Line movement | Average CLV drops below 0% for 30 days | Model is predicting the line, not beating it |
Do not retrain on a bad week alone. Variance kills accurate-but-unlucky models. Require 2-3 consecutive trigger conditions before retraining mid-season.
Step 7: Drift alerting
# scripts/drift_check.py
import sqlite3
import argparse
DRIFT_THRESHOLD = 0.03 # Alert if accuracy drops 3pp below expected
def check_drift(window_days: int = 14):
conn = sqlite3.connect("predictions.db")
query = """
SELECT
COUNT(*) as games,
AVG(CASE WHEN actual_result = ROUND(home_win_prob) THEN 1.0 ELSE 0.0 END) as accuracy
FROM predictions
WHERE actual_result IS NOT NULL
AND game_date >= DATE('now', '-{} days')
""".format(window_days)
result = conn.execute(query).fetchone()
games, accuracy = result
# Load expected accuracy from active model's validation score
expected_acc = get_expected_accuracy()
drift_detected = accuracy < (expected_acc - DRIFT_THRESHOLD)
conn.execute("""
INSERT INTO drift_log (check_date, window_days, games_in_window, accuracy, expected_acc, drift_flag)
VALUES (DATE('now'), ?, ?, ?, ?, ?)
""", (window_days, games, accuracy, expected_acc, int(drift_detected)))
conn.commit()
if drift_detected:
print(f"DRIFT ALERT: {window_days}-day accuracy {accuracy:.1%} vs expected {expected_acc:.1%}")
print(f"Games in window: {games}. Consider retraining if this persists.")
# Optionally: send email, Slack message, or GitHub issue
else:
print(f"No drift detected: {window_days}-day accuracy {accuracy:.1%} (expected {expected_acc:.1%})")
def get_expected_accuracy() -> float:
import json
with open("models/registry.json") as f:
registry = json.load(f)
active = registry["active"]
for v in registry["versions"]:
if v["version"] == active:
return v["val_accuracy"]
return 0.55 # Fallback
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--window", type=int, default=14)
args = parser.parse_args()
check_drift(args.window)
Step 8: Multi-source MCP orchestration
When chaining play-by-play + odds + team stats in one pipeline run:
# scripts/pull_all_sources.py
import asyncio
async def pull_all_data(target_date: str):
"""Pull from multiple sources in parallel. Fail gracefully on any source."""
tasks = [
pull_games(target_date), # 5 credits
pull_odds(target_date), # 10 credits per game
pull_team_stats(target_date), # 5 credits per team
]
results = await asyncio.gather(*tasks, return_exceptions=True)
games, odds, team_stats = results
# Graceful degradation: proceed even if one source fails
if isinstance(odds, Exception):
print(f"WARNING: Odds pull failed: {odds}. Proceeding without odds data.")
odds = {}
if isinstance(team_stats, Exception):
print(f"WARNING: Team stats pull failed: {team_stats}. Using cached stats.")
team_stats = load_cached_team_stats()
return games, odds, team_stats
Credit cost for a full daily NHL run:
get_games(today's slate): 5 creditsget_odds(per game, ~12 games/day average): 120 creditsget_team_stats(per team, 32 teams): 160 credits- Total: ~285 credits per day | ~104,000 per full season
Consider caching team stats (update weekly, not daily) to cut this to ~125 credits/day for games + odds only.
Anti-patterns
| Rationalization | Why It's Wrong | Do This Instead |
|---|---|---|
| "I'll just run the script manually each morning" | You'll miss days, break the streak, and lose comparable data | Set up the cron/Actions now, even if imperfect |
| "I'll overwrite the model file each time I retrain" | Lose the ability to audit which model made which prediction | Version every model, update registry.json to promote |
| "Drift means I need to retrain immediately" | One bad week is variance, not drift | Require 14-day window AND 3pp drop before acting |
| "I'll track predictions in a spreadsheet" | Manual entry breaks, doesn't scale, no query layer | SQLite takes 30 minutes to set up, lasts forever |
| "I'll pull full season odds every day for backtesting" | 1,230+ credits for historical odds; wasteful if you already have them | Backfill once, store locally, pull only new games daily |
Credit Usage
| Operation | Credits | Notes |
|---|---|---|
| Daily games pull | 5 | One get_games call |
| Daily odds (per game) | 10 | Average 12 NHL games/day = 120 credits |
| Team stats (32 teams) | 160 | 5 credits x 32 teams; cache weekly, not daily |
| Line movement check | 25 | Per game; use sparingly |
| Full season historical odds backfill | ~12,300 | ~1,230 regular season games x 10 credits |
What to Do Next
| What You Found | Next Action | Skill |
|---|---|---|
| Pipeline is built and running | Automate your daily picks output | daily-card |
| Model accuracy is drifting | Audit recent predictions against actuals | backtesting |
| Drift confirmed, need to improve features | Re-engineer features with updated data | feature-engineering |
| Want to retrain the model | Full model rebuild with new training data | model-building |
| Want to track edge compression over time | Compare model probability vs. closing line | edge-detection |