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:
- VoteHub poll feed —
api.votehub.com/polls. Every individual poll record: pollster, subject/race, field dates, sample size, population (likely voters / registered voters / adults), partisan-sponsorship and internal-poll flags, sponsor names, answer choices and percentages, and a source URL. Hedge stores this as reported; a poll's own numbers are never altered. - FiveThirtyEight/ABC News pollster ratings — an archived CC BY 4.0 dataset (fetched from the fivethirtyeight/data GitHub archive,
pollster-ratings-combined.csv), covering numeric grade, POLLSCORE, transparency score, AAPOR/Roper membership, percent partisan work, and polls analyzed per pollster. This archive was frozen in 2024 and is no longer updated upstream — Hedge's copy is whatever the archive contains, not a live feed of new 538 ratings.
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:
- 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.) - 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.
- Load — parse and upsert everything into Postgres: the pollster registry, polls, answers, and this run's data-quality findings.
- Recompute — call the database functions that fully rebuild
race_averagesandapproval_averagesfrom scratch, and rebuildgeneric_ballot_averagefrom the loader itself. Every run is a full recompute, never a partial update. - 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_used5. 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 dayHonest 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:
- Reversed field dates. If a poll's start date is after its end date, that's logged as a "serious" finding. The poll itself is still stored — nothing is deleted — but it's skipped when computing the generic ballot average specifically.
- Partisan or internal sponsorship. Every poll's
partisanandinternalflags are stored and visible on the row itself. The generic ballot average excludes any poll with either flag set. - Duplicate answer choices. If a poll's raw answers list the same choice twice, the second occurrence is dropped before it ever reaches the database and logged as a "warning" finding — this is the one check that's a true row-level exclusion, not just a flag.
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
- Feeds are only as fresh as their newest poll. Both derived time series share the same end-bound rule: each stops at the newest qualifying poll's own end date — the generic ballot average at the last generic-ballot poll (§6), the approval average at the newest approval poll for each subject. Neither is extrapolated past its data, so either feed simply goes quiet whenever its source publishes nothing new, and the two can drift out of sync with each other for exactly that reason. Check the most recent dates in
approval_averagesandgeneric_ballot_averagedirectly for the current gap rather than trusting any specific date on this page. - Pollster grouping is a heuristic. The 8-character alphabetic stem (§8) is a starting point pending human curation — it can merge two unrelated pollsters that happen to share a stem, or fail to merge real spelling variants that don't.
pollsters.aliasesis exactly where to check. - Exclusion rules aren't applied uniformly yet. See the scope note in §7 — partisan/internal/reversed-date filtering currently reaches the generic ballot average only.
- One source per metric. VoteHub is the only poll feed, and the archived 538/ABC News file is the only pollster-rating source. If either has a gap or a bias, Hedge inherits it; there's no second source to cross-check against today.
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.