ONE SCHEMA, ONE CURRENCY
Everything lands in one Postgres schema, bridged by a player-ID map across FanGraphs, ESPN and MLB, and every number in the UI passes through a single scoring module. The league's own points, everywhere.
NIGHTS · BUILD NOTES
A full-stack analytics platform for my fantasy baseball league: four data sources fused into one Postgres schema, and every number scored in the league's own points formula.
ESPN gives a points league a scoreboard, not analytics. An honest answer to "who do I pick up this week" takes four sources: rest-of-season projections, live rosters and matchups, season-to-date actuals, and the skill-versus-luck signals underneath. Each has its own player IDs, and none of them speaks the league's scoring.
Everything lands in one Postgres schema, bridged by a player-ID map across FanGraphs, ESPN and MLB, and every number in the UI passes through a single scoring module. The league's own points, everywhere.
Forecasts blend a Marcel baseline, season-to-date actuals, recent form, and a Statcast skill signal, with weights that shift as the season goes and always sum to one. Clicking any forecast opens the engine's work: each component, its weight, and the call it made. The forecast explains itself instead of asking to be trusted.
Every player is measured against the best free agent at his position. That one subtraction turns projections into add, drop and trade decisions instead of leaderboards.
Weekly head-to-head comes with a win probability and a daily timeline that separates locked days from projected ones, down to per-start pitching projections. A planner lists every projected MLB start for the next two weeks and who holds the pitcher.

An hourly sync pulls rosters and matchups. A daily 6 AM run pulls projections, game logs and Statcast leaderboards, then recomputes every forecast. Each stage has a manual trigger and a CSV fallback for the day a source changes shape.
The real league sits behind a sign-in; the demo league is invented, so there is nothing real to leak. Isolation is enforced by Postgres, not by carefulness: the demo connects as a role with no privilege on the real schema, and the gate lists public paths only, so a route nobody has written yet fails closed.
It runs my league today; the demo is open to anyone. Every demo page says it plainly: real players, invented league.