An open API service indexing awesome lists of open source software.

https://github.com/soulin02/linedrift

Football odds tracker: snapshots bookmaker odds over time, computes no-vig consensus probabilities, and flags value. Educational analytics project.
https://github.com/soulin02/linedrift

data-pipeline drizzle football neon nextjs no-vig odds postgres recharts sports-betting typescript vercel

Last synced: 1 day ago
JSON representation

Football odds tracker: snapshots bookmaker odds over time, computes no-vig consensus probabilities, and flags value. Educational analytics project.

Awesome Lists containing this project

README

          

# LineDrift

[![CI](https://github.com/souliN02/linedrift/actions/workflows/ci.yml/badge.svg)](https://github.com/souliN02/linedrift/actions/workflows/ci.yml)

**A football odds tracker that builds its own historical dataset** — snapshotting
bookmaker odds every 4 hours, computing no-vig consensus probabilities, and
flagging prices that beat the market.

**Live demo → https://linedrift.vercel.app** · **How it works → [/about](https://linedrift.vercel.app/about)**

![LineDrift demo — dashboard, closing-line report, odds-movement chart toggles](docs/demo.gif)

Free odds APIs only give you the _current_ price; history is paywalled. LineDrift
manufactures its own history under a real constraint — **500 free API credits a
month** — which is the point of the project: a data pipeline sized around a hard
budget, not a CRUD demo.

## Features

- **Odds snapshots every 4 hours** — Premier League, Danish Superliga, and the
FIFA World Cup while it's on — into Postgres via a scheduled GitHub Actions job.
- **Dashboard** of upcoming matches with best available price per outcome, the
offering bookmaker, lowest overround, and value badges.
- **Match detail** with a Recharts odds-movement chart (line per bookmaker,
Home/Draw/Away toggle) and a per-bookmaker comparison table.

![Match detail — odds movement chart and bookmaker table](docs/match-detail.png)

- **Value engine** — implied probability, overround (vig), no-vig fair
probabilities, market consensus, and edge-vs-consensus flags, all
[pure and unit-tested](src/lib/odds-math.ts).
- **Closing line value** — every finished match gets a closing-line report:
opening vs closing price per bookmaker and outcome, the biggest move
headlined, and the closing no-vig consensus. The classic test of whether
"value" was real — powered entirely by the self-built history.
- **Pipeline health** — every ingestion run records what it saw and what was
left of the monthly credit budget, shown live on
[/about](https://linedrift.vercel.app/about); failed runs included.
- **`/about`** page explaining the pipeline and the maths with a live worked example.
- TypeScript strict throughout, Zod-validated API boundary, 150+ unit tests plus
a Playwright e2e smoke, green CI.

## Architecture

```mermaid
flowchart LR
GHA[GitHub Actions cron - every 4h] -->|POST + Bearer CRON_SECRET| SNAP["/api/cron/snapshot"]
SNAP -->|fetch h2h odds, 3 sport keys| ODDS[The Odds API]
SNAP -->|Zod validate + upsert| DB[(Neon Postgres)]
WEB[Next.js server components] --> DB
CHART["/api/matches/id/history"] --> DB
```

Pages read Postgres directly in React Server Components (no client fetching for
the initial render). The only mutating endpoint is the snapshot route, guarded
by a bearer secret. Raw API JSON is validated by Zod in a single boundary file so
the rest of the app never sees an untrusted shape.

## Stack

Next.js (App Router) · TypeScript strict · Tailwind CSS + shadcn/ui · Drizzle
ORM + Neon Postgres · Zod · Recharts · Vitest + Testing Library · GitHub
Actions + Vercel.

## Local setup

Uses [pnpm](https://pnpm.io) (pinned via the `packageManager` field).

```bash
git clone https://github.com/souliN02/linedrift.git
cd linedrift
pnpm install
cp .env.example .env # fill in DATABASE_URL (see below)

pnpm db:migrate # apply Drizzle migrations to the DB
pnpm db:seed # load tests/fixtures/odds-response.json into the DB
pnpm dev # http://localhost:3000
```

`pnpm db:seed` populates the UI entirely from the saved fixture — **development
and tests never call the live API** (the free tier is reserved for the scheduled
job). You only need a `DATABASE_URL`; `ODDS_API_KEY` and `CRON_SECRET` are only
required to run the live ingestion job.

### Run the tests

```bash
pnpm test # vitest run — odds math, Zod boundary, snapshot route, dashboard render
pnpm test:e2e # Playwright smoke — drives the real app; needs DATABASE_URL + a running dev server
pnpm typecheck # tsc --noEmit
pnpm lint # eslint
```

### Scripts

```bash
pnpm dev # local dev server
pnpm build # production build
pnpm lint # eslint
pnpm typecheck # tsc --noEmit
pnpm test # vitest run
pnpm format # prettier --write .
pnpm db:generate # drizzle-kit generate migrations
pnpm db:migrate # apply migrations
pnpm db:seed # seed local/dev DB from the fixture
```

## Ingestion pipeline

- **`POST /api/cron/snapshot`** is the only mutating endpoint. It requires
`Authorization: Bearer ${CRON_SECRET}`, fetches h2h odds for every configured
league (EPL, Superliga, and the World Cup while it runs) from
[The Odds API](https://the-odds-api.com), validates them through the Zod
boundary in [`src/lib/odds-api.ts`](src/lib/odds-api.ts), upserts via
[`src/db/ingest.ts`](src/db/ingest.ts), logs `x-requests-remaining`, and
returns `{ matches, snapshots, creditsRemaining }`. A unique
`(match, bookmaker, captured_at)` index makes duplicate runs harmless.
- **[`.github/workflows/snapshot.yml`](.github/workflows/snapshot.yml)** curls
that endpoint on a `0 */4 * * *` schedule (and on manual `workflow_dispatch`).

### Configuration

| Where | Name | Value |
| ------ | -------------- | ------------------------------------------------------- |
| Vercel | `DATABASE_URL` | Neon connection string |
| Vercel | `ODDS_API_KEY` | the-odds-api.com key |
| Vercel | `CRON_SECRET` | long random string |
| GitHub | `CRON_SECRET` | secret — same value as Vercel |
| GitHub | `SNAPSHOT_URL` | variable — `https://.vercel.app/api/cron/snapshot` |

After setting these, trigger `snapshot.yml` via **workflow_dispatch** to confirm
rows land in Neon and credits are logged in the Vercel function logs.

## Engineering decisions

- **Snapshots instead of a paywalled historical API.** The Odds API charges 10×
for history. Recording the live `h2h` market costs `regions × markets` credits
per league per run — 2 off-season (EPL + Superliga) and 3 while the FIFA World
Cup has fixtures (its key bills 0 between tournaments). Every 4 hours is 6
runs/day ≈ **360 of the free tier's 500 credits/month**, rising to ~486 in a
World Cup month — still inside the tier. The schedule is the single knob.
- **Zod at the boundary.** All external JSON is parsed and validated in
[`src/lib/odds-api.ts`](src/lib/odds-api.ts) before touching the app, so a
provider change (or a malformed payload) is contained to one file.
- **`numeric`, not float, for odds.** Prices are stored as Postgres `numeric` to
avoid binary-float drift; Drizzle returns them as strings and they are converted
to `number` in exactly one place ([`src/db/queries.ts`](src/db/queries.ts)),
never inline.
- **Odds maths is pure and test-first.** Every calculation lives in
[`src/lib/odds-math.ts`](src/lib/odds-math.ts) as a pure function with
table-driven tests (implied, overround, no-vig, consensus, edge, best price),
covering edge cases like fewer than three bookmakers and missing draw prices.
- **GitHub Actions as the scheduler, not Vercel Cron.** Vercel's Hobby plan caps
cron at one run per day; the pipeline needs every 4 hours, so the schedule lives
in GitHub Actions (a few minutes of drift is fine here).
- **Server components read the DB directly.** No client data fetching for the
initial render; interactivity (chart toggles) is the only client-side code.

## Roadmap

Deliberately out of scope for now (closing-line value graduated from this list
in v1.1):

- Email alerts when an edge over _X_% appears
- More markets (totals) or arbitrage detection across bookmakers
- A public, rate-limited JSON API

## Disclaimer

Educational analytics project. **Not betting advice.**