Hedge
← Hedge

Methodology

Hedge publishes one thing: polling averages, computed by a fixed set of rules from public data. This page is that fixed set of rules — the same ones the code runs — so anyone can check any number on this site against its inputs.

1. What Hedge is — and isn't

Hedge publishes polling averages. That's the whole product: take the polls that exist for a race, a subject's approval rating, or the generic congressional ballot, weight them by how recent and how large they are, and publish the result.

Hedge does not publish forecasts, seat projections, or win probabilities — and it never will. An average is a statement about polls that already happened; you can pull the same polls and recompute it yourself. A forecast is a statement about an election that hasn't happened yet, produced by a model with assumptions baked in that a reader can't check against anything. Hedge only publishes the kind of number a visitor can verify.

2. Data sources & licenses

Hedge ingests from exactly two upstream sources, both licensed CC BY 4.0:

Hedge did not create either dataset. It doesn't conduct polls, doesn't rate pollsters, and doesn't adjust either input before storing it. The only original work described on this page is the fetch schedule, the pollster-identity matching between the two sources, and the averaging below.

3. Pipeline

Every 6 hours, a GitHub Actions workflow runs the full pipeline, in order:

  1. Fetch — pull the current VoteHub polls feed and the archived 538/ABC News pollster-ratings CSV. (The schedule is 17 */6 * * *: 00:17, 06:17, 12:17, and 18:17 UTC — deliberately offset from the top of the hour.)
  2. Snapshot — write both raw files into the repository and commit them only if they changed, so the exact bytes behind any given run stay preserved and diffable.
  3. Load — parse and upsert everything into Postgres: the pollster registry, polls, answers, and this run's data-quality findings.
  4. Recompute — call the database functions that fully rebuild race_averages and approval_averages from scratch, and rebuild generic_ballot_average from the loader itself. Every run is a full recompute, never a partial update.
  5. Rebuild — the site's static pages rebuild against the database roughly once an hour.

At no point does a visitor's request trigger a computation. The front end only ever reads already-computed tables — it never aggregates raw polls at request time. Every number on this site was computed on a schedule, by the database, before you asked for the page.

4. Race averages

For each 2026 general-election Senate and Governor race, Hedge first has to decide which two candidates the average is even about. That decision has two tiers. First, verified nominees: a curated race_nominees table records both parties' nominees, sourced from official primary results. When a race has a verified pair there and at least one poll asked about that exact pairing, that pairing is the matchup — no counting involved. Otherwise — before a primary is decided, or before any poll has tested the verified pair — Hedge falls back to co-polling: it looks at every candidate already resolved to a party in the candidate registry (§8) for that state and office, counts how many polls tested each possible Democrat-vs-Republican pairing, and picks the most-polled pairing. Ties are broken alphabetically by candidate name — a fixed rule, not a judgment call made at query time. Every race records which tier produced its matchup in the matchup_source column: nominees or co-polling. Whichever tier picks the matchup, only general-election polls (race_class = 'general') ever feed the average — primary polls never do.

Every poll of that matchup is then weighted by how recently it closed (a 45-day half-life — a poll's influence roughly halves every 45 days after its last field date) and its sample size (bigger samples count more, with diminishing returns above 3,000 respondents; a poll that doesn't report a sample size is treated as 600 for weighting purposes only). There's no age cutoff beyond that decay — a very old poll of the same matchup still contributes, just at a vanishingly small weight. Approval and generic-ballot averages below use a hard trailing window instead; race averages don't.

Below 3 qualifying polls, Hedge still shows the computed percentages but sets leader_party to null and low_data to true, rather than declaring a leader off thin data.

Each race also publishes avg_grade: the weight-weighted mean of the archived 538 numeric grade (0–3) over the polls used — the same recency-and-sample weights as the average itself, taken over graded polls only. When under half of a race's polls come from grade-matched pollsters, avg_grade is null rather than a number built mostly on missing data. It describes the polls behind the average; it never changes the average itself.

weight = power(0.5, greatest(current_date - end_date, 0) / 45.0)
       * sqrt(least(coalesce(sample_size, 600), 3000) / 1000.0)

dem_pct = round(sum(dem_pct * weight) / sum(weight), 2)
rep_pct = round(sum(rep_pct * weight) / sum(weight), 2)
margin  = round(dem_pct - rep_pct, 2)

low_data = (polls_used < 3)   -- leader_party is null whenever this is true

avg_grade = round(sum(weight * pollster_538_grade) / sum(weight), 2)
  -- over graded polls only; null when graded polls < half of polls_used

5. Approval averages

Each subject with poll_type = 'approval' polls (a person or institution being asked about — a president, a legislative body) gets one row per day it has been polled. For each day, Hedge recomputes the average using only that subject's approval polls whose end date falls in the trailing 270 days; anything older is excluded entirely, not just decayed.

The weighting is the same formula as race averages: a 45-day recency half-life times the same sample-size damping (600 default, 3,000 cap). approve_pct requires a poll to report an "Approve" answer; disapprove_pct skips any poll in the window that doesn't report a "Disapprove" choice (some polls only ask a single approve/not-approve question). net is approve minus disapprove, treating a missing disapprove as 0.

Unlike race averages, there's no minimum-poll floor here — a day backed by a single poll still publishes a value. polls_in_window is published alongside every row specifically so a reader can weigh that for themselves.

-- eligibility for day D:
end_date <= D  and  end_date > D - 270

weight = power(0.5, greatest(D - end_date, 0) / 45.0)
       * sqrt(least(coalesce(sample_size, 600), 3000) / 1000.0)

approve_pct    = round(sum(approve_pct * weight) / sum(weight), 2)
disapprove_pct = round(sum(disapprove_pct * weight) / sum(weight), 2)  -- null if no poll in window reports it
net            = round(approve_pct - coalesce(disapprove_pct, 0), 2)

6. Generic congressional ballot average

This is Hedge's oldest average, and it uses a different formula from the two above — the schema's own comment on the newer race-average function describes its weighting as "the same spirit as" the generic ballot's, not the same formula. They haven't been unified yet. This page says so plainly, because the point of Hedge is that you shouldn't have to take that on faith.

A poll counts toward the generic ballot only if it's poll_type = 'generic-ballot', isn't flagged partisan or internal, doesn't have a reversed field-date range, and reports both a Democratic and a Republican number. Each qualifying poll is weighted by recency (a 30-day half-life — noticeably faster decay than the 45-day half-life used for race and approval averages), the same sample-size damping used everywhere else (600 default, 3,000 cap), and — the one ingredient the other two averages don't use at all — a pollster-quality multiplier: 0.6 + 0.4 × (a pollster's archived 538 numeric grade ÷ 3) if that pollster is grade-matched, or a flat 0.8 if it isn't. Only polls within 120 days of the day being computed count, and a day only publishes once at least 3 polls qualify. The series also has a hard end: it stops at the newest qualifying generic-ballot poll's end date — no day is published past the last real poll, and nothing is extrapolated beyond the data.

quality_weight = 0.6 + 0.4 * (pollster_538_grade / 3)   if grade-matched
               = 0.8                                     otherwise

weight = (0.5 ** (age_days / 30)) * sqrt(min(sample_size or 600, 3000) / 1000) * quality_weight
  -- age_days = day - poll.end_date; only 0 <= age_days <= 120 is eligible

dem    = round(sum(weight * dem_pct) / sum(weight), 2)
rep    = round(sum(weight * rep_pct) / sum(weight), 2)
margin = round(dem - rep, 2)
  -- published only once at least 3 polls qualify for that day

Honest note. Race and approval averages use a 45-day half-life and no pollster-quality term. The generic ballot uses a 30-day half-life and does weight by pollster quality. Both are the same family — exponential recency decay × sample-size damping — with different constants and one extra term, not the same formula wearing different clothes.

Unifying all three under one documented formula is on the roadmap; it hasn't shipped yet.

7. What we exclude — and what we only flag

Every load runs three checks against the incoming polls:

One scope note, stated plainly because burying it would defeat the point of this page: the partisan/internal filter and the reversed-date exclusion currently run in the generic-ballot pipeline only. Race averages and approval averages are computed directly from polls and poll_answers with no partisan, internal, or reversed-date filter of their own yet. A partisan-sponsored, internal, or reversed-date poll of a Senate or Governor race — or of an approval subject — would currently be included in those two averages. Nothing about this is hidden: the partisan, internal, and start_date columns are public on every poll row, and any reversed-date poll is flagged regardless of which average(s) it ends up feeding.

Every finding — good, warning, or serious — lands in a public data_quality_findings table tied to the run that produced it. As the schema itself puts it: a commons that hides its own errors is not a commons.

8. Pollster registry & candidate parties

Pollster names arrive inconsistently — "HarrisX", "HarrisX/Harris", and "HarrisX/Harris Poll" can all be the same pollster in the wild. Hedge groups raw names by a coarse stem — lowercase the name, strip everything that isn't a–z, keep the first 8 characters — and every raw string sharing a stem becomes an alias of one registry row; the most frequently seen raw string becomes that row's canonical display name. This is explicitly a starting point, not a finished answer: every alias grouping is stored in pollsters.aliases, in full view, specifically so a coarse match that merged two different pollsters (or failed to merge real variants) can be caught and fixed by a human — see Limitations.

stem(name) = re.sub(r"[^a-z]", "", name.lower())[:8]

Candidate party labels work the other direction: assisted, then reviewed, never silently overwritten. Every candidate name seen in a 2026 general-election Senate or Governor poll is registered with party left null. A narrow auto-classify pass resolves only the unambiguous cases immediately — literal non-candidate answers ("Undecided", "Other", "Someone else", "Refused", and similar) get party X; a literal "Dem/Democrat/Democratic" answer gets D; a literal "Rep/Republican/GOP" answer gets R. Every actual named candidate stays unresolved until curated by a person or an agent-plus-review pass, and party_source records how each value was set (values like literal-label, non-candidate, and agent-attributed sources among them).

The one rule that's never broken: once party is non-null, nothing in the ETL can overwrite it — every auto-classify step is guarded by a "still null" check. This matters beyond bookkeeping: race averages (§4) only consider matchups between a candidate resolved to D and one resolved to R, so an unresolved candidate can't anchor a published race average.

9. Limitations

10. Verify it yourself

Every table behind every number on this site — polls, poll_answers, pollsters, candidates, race_averages, approval_averages, generic_ballot_average, data_quality_findings, even the ingest_runs log of every pipeline run — is Postgres with row-level security set to allow public, read-only access to all of it. There's no number on the homepage or this page that isn't sitting in a table you could query yourself; nothing is computed only behind the scenes and thrown away.

The whole site rebuilds against that database on a fixed schedule — roughly once an hour — never on demand, never customized per visitor. What you're looking at right now is exactly what everyone else is looking at right now.

Source code and direct database access details aren't linked from this page yet.