viewof groups = Inputs.checkbox(D.groups, {value: D.groups, label: "Technology"})
viewof rounds = Inputs.checkbox(D.rounds, {value: D.rounds, label: "Allocation round",
format: r => r === "IC" ? "IC (Investment Contract)" : r})Without the hype
Contracts for Difference Dashboard
Energy
An interactive dashboard of every UK Contract for Difference: net payments, cost at strike price, generation, and strike against market price, filtered by technology, allocation round and years, in cash or today’s money.
Published
6 November 2026
D = FileAttachment("cfd.json").json()
latest = new Date(D.latest)
latestYear = latest.getUTCFullYear()
latestLong = latest.toLocaleDateString("en-GB", {day: "numeric", month: "long", year: "numeric", timeZone: "UTC"})
latestShort = latest.toLocaleDateString("en-GB", {day: "numeric", month: "long", timeZone: "UTC"})
monthDate = D.months.map(s => new Date(Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, 1)))
fmt = new Intl.NumberFormat("en-GB", {maximumFractionDigits: 0})
money = x => {
const a = Math.abs(x), s = x < 0 ? "−£" : "£";
if (a >= 1e9) return `${s}${(a / 1e9).toFixed(a >= 1e10 ? 1 : 2)}bn`;
if (a >= 1e6) return `${s}${fmt.format(a / 1e6)}m`;
return `${s}${fmt.format(a)}`;
}
perMwh = x => Number.isFinite(x) ? `${Math.round(x) < 0 ? "−" : ""}£${Math.abs(x).toFixed(0)}/MWh` : "—"
cpiMonth = new Date(Date.UTC(+D.cpi_month.slice(0, 4), +D.cpi_month.slice(5, 7) - 1, 1)).toLocaleDateString("en-GB", {month: "long", year: "numeric", timeZone: "UTC"})
roundColours = {
const r = D.rounds.filter(r => r !== "IC");
const ramp = d3.interpolateRgb("#9cc3e4", "#14365c");
return Object.fromEntries([["IC", "#d0663a"], ...r.map((k, i) => [k, ramp(r.length > 1 ? i / (r.length - 1) : 1)])]);
}Every UK Contract for Difference that has generated so far, from the Low Carbon Contracts Company’s settlement data to . Pick technologies, allocation rounds and years, and every figure, chart and the table below follow. At the end is every contract that has been signed but not started yet, Dogger Bank among them. The CfD cost comparison explains what the numbers mean, and what the contracts will cost over their lifetime projects them forward.
What to include
Years and money
viewof fromYear = Inputs.range([2016, latestYear], {value: 2016, step: 1, label: "From the start of"})
viewof toYear = Inputs.range([2016, latestYear], {value: latestYear, step: 1, label: "To the end of"})
viewof money_ = Inputs.radio(["Money of the time", "Today's money"], {value: "Money of the time", label: "Pounds"})span = [Math.min(fromYear, toYear), Math.max(fromYear, toYear)]
sel = {
const lo = D.months.findIndex(m => +m.slice(0, 4) >= span[0]);
let hi = D.months.length - 1;
while (hi > 0 && +D.months[hi].slice(0, 4) > span[1]) hi--;
const k = money_ === "Today's money" ? D.real : D.real.map(() => 1);
const gs = new Set(groups), rs = new Set(rounds);
return D.contracts.filter(c => gs.has(c.group) && rs.has(c.round)).map(c => {
const m = c.m.filter(r => r[0] >= lo && r[0] <= hi)
.map(([i, mwh, pay, cost, mv]) => ({i, date: monthDate[i], mwh, pay: pay * k[i], cost: cost * k[i], mv: mv * k[i]}));
const t = {mwh: d3.sum(m, r => r.mwh), pay: d3.sum(m, r => r.pay), cost: d3.sum(m, r => r.cost), mv: d3.sum(m, r => r.mv)};
return {...c, months: m, ...t, key: colourBy === "Technology" ? c.group : c.round};
}).filter(c => c.months.length);
}
totals = ({
mwh: d3.sum(sel, c => c.mwh), pay: d3.sum(sel, c => c.pay), cost: d3.sum(sel, c => c.cost), mv: d3.sum(sel, c => c.mv),
n: sel.length, mw: d3.sum(sel, c => c.mw ?? 0)
})
colour = colourBy === "Technology"
? {domain: D.groups.filter(g => groups.includes(g)), range: D.groups.filter(g => groups.includes(g)).map(g => D.colours[g])}
: {domain: D.rounds.filter(r => rounds.includes(r)), range: D.rounds.filter(r => rounds.includes(r)).map(r => roundColours[r])}
interval = step === "Year" ? d3.utcYear : step === "Quarter" ? d3.utcMonth.every(3) : d3.utcMonth
periodLabel = d => step === "Year" ? `${d.getUTCFullYear()}${d.getUTCFullYear() === latestYear ? ` to ${latest.toLocaleDateString("en-GB", {month: "short"})}` : ""}`
: step === "Quarter" ? `Q${Math.floor(d.getUTCMonth() / 3) + 1} ${d.getUTCFullYear()}`
: d.toLocaleDateString("en-GB", {month: "short", year: "numeric", timeZone: "UTC"})html`<div class="stat-row">
<div class="stat"><span class="stat-value">${money(totals.pay)}</span><span class="stat-label">net CfD payments, ${span[0]} to ${span[1] === latestYear ? "now" : span[1]}</span></div>
<div class="stat"><span class="stat-value">${money(totals.cost)}</span><span class="stat-label">paid for the power at strike prices</span></div>
<div class="stat"><span class="stat-value">${fmt.format(totals.mwh / 1e6)} TWh</span><span class="stat-label">generated under ${totals.n} contract${totals.n === 1 ? "" : "s"}</span></div>
<div class="stat"><span class="stat-value">${perMwh(totals.cost / totals.mwh)}</span><span class="stat-label">average strike price, against ${perMwh(totals.mv / totals.mwh)} market price</span></div>
<div class="stat"><span class="stat-value">${perMwh(totals.pay / totals.mwh)}</span><span class="stat-label">net subsidy per MWh: ${totals.cost ? Math.round(100 * totals.pay / totals.cost) : 0}% of the strike-price cost</span></div>
</div>`Net payments
html`<p class="chart-sub">Net CfD payments by ${step.toLowerCase()}, coloured by ${colourBy.toLowerCase()} (${money_ === "Today's money" ? "today's money" : "money of the time"}). Below zero, market prices were above strike prices and generators paid money back.</p>`{
const rows = sel.flatMap(c => c.months.map(r => ({date: interval.floor(r.date), key: c.key, pay: r.pay})));
const agg = d3.flatRollup(rows, v => d3.sum(v, r => r.pay), r => +r.date, r => r.key)
.map(([t, key, pay]) => ({date: new Date(t), key, pay}));
const net = d3.rollups(agg, v => d3.sum(v, r => r.pay), r => +r.date).map(([t, pay]) => ({date: new Date(t), pay}));
return Plot.plot({
className: "cfd-plot", width, height: 380, marginLeft: 60, marginRight: 16,
x: {type: "utc", label: null},
y: {label: "£ million", grid: true, tickFormat: v => fmt.format(v / 1e6)},
color: {...colour, legend: true},
marks: [
Plot.rectY(agg, {x: "date", interval, y: "pay", fill: "key", insetLeft: step === "Month" ? 0 : 1, insetRight: step === "Month" ? 0 : 1,
tip: true, title: d => `${periodLabel(d.date)}\n${d.key}: ${money(d.pay)}`}),
step !== "Month" ? Plot.dot(net, {x: d => new Date((+d.date + +interval.offset(d.date, 1)) / 2), y: "pay", symbol: "diamond",
fill: "var(--cfd-ink)", r: 4, tip: true, title: d => `${periodLabel(d.date)}\nNet total: ${money(d.pay)}`}) : null,
Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.6})
]
});
}Strike price and market price
html`<p class="chart-sub">Output-weighted average strike price of the selected contracts against the market reference price they were paid against, by ${step.toLowerCase()} (£/MWh, ${money_ === "Today's money" ? "today's money" : "money of the time"}). The shaded gap is what bill payers make up.</p>`{
const rows = sel.flatMap(c => c.months).map(r => ({...r, date: interval.floor(r.date)}));
const p = d3.rollups(rows, v => ({mwh: d3.sum(v, r => r.mwh), cost: d3.sum(v, r => r.cost), mv: d3.sum(v, r => r.mv)}), r => +r.date)
.map(([t, s]) => ({date: new Date(new Date(t).getTime()), strike: s.cost / s.mwh, market: s.mv / s.mwh, mwh: s.mwh}))
.filter(d => d.mwh > 0).sort((a, b) => a.date - b.date);
const long = p.flatMap(d => [{date: d.date, series: "Strike price", v: d.strike}, {date: d.date, series: "Market reference price", v: d.market}]);
return Plot.plot({
className: "cfd-plot", width, height: 340, marginLeft: 48, marginRight: 16,
x: {type: "utc", label: null},
y: {label: "£ per MWh", grid: true},
color: {domain: ["Strike price", "Market reference price"], range: ["#d0663a", "var(--cfd-ink)"], legend: true},
marks: [
Plot.areaY(p, {x: "date", y1: "market", y2: "strike", fill: "var(--cfd-fill)", curve: "step-after"}),
Plot.line(long, {x: "date", y: "v", stroke: "series", strokeWidth: 2, curve: "step-after"}),
Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.4}),
Plot.tip(p, Plot.pointerX({x: "date", y: "strike",
title: d => `${periodLabel(d.date)}\nStrike: ${perMwh(d.strike)}\nMarket: ${perMwh(d.market)}\n${fmt.format(d.mwh / 1e3)} GWh generated`}))
]
});
}Cumulative payments per contract
html`<p class="chart-sub">Each selected contract's running total of net payments over the chosen years. ${picked.length
? `Highlighted: ${picked.map(c => c.name).join(", ")}. Tick contracts in the table below to change this.`
: "Tick contracts in the table below to highlight them; the three largest are highlighted until you do."}</p>`{
const lines = sel.flatMap(c => {
let run = 0;
return c.months.map(r => ({id: c.id, name: c.name, key: c.key, date: r.date, cum: (run += r.pay)}));
});
const back = lines.filter(d => !pickedIds.has(d.id)), front = lines.filter(d => pickedIds.has(d.id));
const ends = d3.rollups(front, v => v[v.length - 1], d => d.id).map(([, d]) => d);
// nudge the end labels apart so none sit within 5% of the chart's height of another
const [lo, hi] = d3.extent(lines, d => d.cum), gap = 0.05 * ((hi - lo) || 1);
ends.sort((a, b) => b.cum - a.cum).forEach((d, i) => { d.ly = i && ends[i - 1].ly - d.cum < gap ? ends[i - 1].ly - gap : d.cum; });
return Plot.plot({
className: "cfd-plot", width, height: 420, marginLeft: 60, marginRight: 16,
x: {type: "utc", label: null},
y: {label: "£ million", grid: true, tickFormat: v => fmt.format(v / 1e6)},
color: {...colour, legend: true},
marks: [
Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.5}),
Plot.line(back, {x: "date", y: "cum", z: "id", stroke: "var(--cfd-context)", strokeWidth: 1}),
Plot.line(front, {x: "date", y: "cum", z: "id", stroke: "key", strokeWidth: 2.5}),
Plot.text(ends, {x: "date", y: "ly", text: "name", textAnchor: "end", dy: -8, fill: "currentColor", stroke: "var(--cfd-bg)", strokeWidth: 3}),
Plot.tip(lines, Plot.pointer({x: "date", y: "cum", title: d => `${d.name}\n${d.date.toLocaleDateString("en-GB", {month: "short", year: "numeric", timeZone: "UTC"})}: ${money(d.cum)}`}))
]
});
}Every contract
rowsSorted = sel.map(c => ({
id: c.id, name: c.name, tech: c.tech, round: c.round, mw: c.mw, first: new Date(c.first),
twh: c.mwh / 1e6, pay: c.pay, cost: c.cost, strike: c.mwh ? c.cost / c.mwh : NaN, market: c.mwh ? c.mv / c.mwh : NaN,
subsidy: c.mwh ? c.pay / c.mwh : NaN, strikeNow: c.strike
})).sort((a, b) => b.pay - a.pay)viewof search = Inputs.search(rowsSorted, {placeholder: "Search contracts, technologies or rounds", label: "Find"})viewof table = Inputs.table(search, {
columns: ["name", "tech", "round", "mw", "first", "twh", "pay", "strike", "market", "subsidy", "strikeNow"],
header: {name: "Contract", tech: "Technology", round: "Round", mw: "MW", first: "First paid", twh: "TWh",
pay: "Net payments", strike: "Avg strike £/MWh", market: "Avg market £/MWh", subsidy: "Net £/MWh", strikeNow: "Strike now £/MWh"},
format: {
mw: v => v == null ? "—" : fmt.format(v),
first: d => d.toLocaleDateString("en-GB", {month: "short", year: "numeric"}),
twh: v => v.toFixed(2), pay: money,
strike: v => Number.isFinite(v) ? v.toFixed(0) : "—", market: v => Number.isFinite(v) ? v.toFixed(0) : "—",
subsidy: v => Number.isFinite(v) ? v.toFixed(0) : "—", strikeNow: v => v.toFixed(2)
},
width: {name: 220},
layout: "auto", rows: 16, required: false
}){
const csv = d3.csvFormat(rowsSorted.map(r => ({
contract: r.name, cfd_id: r.id, technology: r.tech, round: r.round, capacity_mw: r.mw ?? "", first_paid: r.first.toISOString().slice(0, 10),
twh: r.twh.toFixed(4), net_payments_gbp: Math.round(r.pay), cost_at_strike_gbp: Math.round(r.cost),
avg_strike_gbp_mwh: Number.isFinite(r.strike) ? r.strike.toFixed(2) : "", avg_market_gbp_mwh: Number.isFinite(r.market) ? r.market.toFixed(2) : "",
strike_now_gbp_mwh: r.strikeNow
})));
const a = html`<a download="cfd-contracts-${span[0]}-${span[1]}.csv">Download this table as CSV</a>`;
a.href = URL.createObjectURL(new Blob([csv], {type: "text/csv"}));
return html`<p class="cfd-note" style="margin-top:8px">${a} (${money_ === "Today's money" ? "today's money" : "money of the time"}, ${span[0]} to ${span[1]}).</p>`;
}Who hasn’t taken up their CfD yet
U = D.unstarted
byStatus = d3.rollup(U, v => ({n: v.length, gw: d3.sum(v, r => r.mw) / 1e3}), r => r.status)
statusOf = s => byStatus.get(s) ?? {n: 0, gw: 0}
WAITING = ["Complete, CfD not started", "Under construction", "Not yet building", "Build stage not known"]
waiting = U.filter(r => WAITING.includes(r.status))
waitingGw = d3.sum(waiting, r => r.mw) / 1e3
// whole months between two dates
monthsBetween = (a, b) => (b.getUTCFullYear() - a.getUTCFullYear()) * 12 + b.getUTCMonth() - a.getUTCMonth() - (b.getUTCDate() < a.getUTCDate() ? 1 : 0)
complete = U.filter(r => r.status === "Complete, CfD not started")
.map(r => ({...r, since: monthsBetween(new Date(r.complete), latest)}))
.sort((a, b) => b.since - a.since || (a.start ?? "").localeCompare(b.start ?? ""))
longest = complete[0]
offshore34 = waiting.filter(r => r.tech === "Offshore Wind" && ["AR3", "AR4"].includes(r.round) && r.strike)
gw = x => `${x.toFixed(x < 10 ? 1 : 0)} GW`
monthYear = s => new Date(s).toLocaleDateString("en-GB", {month: "long", year: "numeric", timeZone: "UTC"})html`<p>A CfD only starts paying once the generator sets its start date, and until then the contract shows up in LCCC's register of contracts but not in the payments above. <strong>${fmt.format(U.length)} contracts with ${gw(d3.sum(U, r => r.mw) / 1e3)} of capacity</strong> have been signed and not been paid anything yet. ${fmt.format(waiting.length)} of them, ${gw(waitingGw)}, have passed their milestone and are waiting to start, Dogger Bank among them. The government's <a href="https://www.gov.uk/government/collections/renewable-energy-planning-data">Renewable Energy Planning Database</a> shows how far each has got with building, and which are already complete.</p>`html`<div class="stat-row">
<div class="stat"><span class="stat-value">${gw(statusOf("Complete, CfD not started").gw)}</span><span class="stat-label">complete, CfD not started: ${statusOf("Complete, CfD not started").n} contracts, the longest ${longest ? `${longest.since} months` : "—"} since completion</span></div>
<div class="stat"><span class="stat-value">${gw(statusOf("Under construction").gw)}</span><span class="stat-label">under construction: ${statusOf("Under construction").n} contracts</span></div>
<div class="stat"><span class="stat-value">${gw(statusOf("In development").gw)}</span><span class="stat-label">in development: ${statusOf("In development").n} contracts that haven't reached their milestone yet</span></div>
<div class="stat"><span class="stat-value">${gw(statusOf("Terminated").gw)}</span><span class="stat-label">terminated: ${statusOf("Terminated").n} contracts given up before they started</span></div>
</div>`html`<p><strong>Complete, CfD not started.</strong> A generator can choose when its CfD begins, within the window its contract sets, and until it does it sells its power at the market price. ${longest ? `${longest.name.replace(/ Ph \d+$/, "")} has been fully operational since ${monthYear(longest.complete)}, ${longest.since} months, and its CfD isn't expected to start until ${monthYear(longest.start)}. ` : ""}The round 3 and 4 offshore wind farms won the lowest strike prices of any CfDs, £${d3.min(offshore34, r => r.strike).toFixed(0)}–${d3.max(offshore34, r => r.strike).toFixed(0)}/MWh in today's money, while the day-ahead market price has averaged <strong>£${D.wholesale_12m.toFixed(0)}/MWh</strong> over the last year, so a wind farm that is already generating earns more by waiting. Some of the wait is set by the contract, which can't start before the window around the delivery year it bid for. Once a CfD starts, the generator pays back the difference whenever the market price is above its strike price.</p>
<p><strong>Under construction</strong> projects can already be generating: Dogger Bank A has exported power since 2023, but the planning database only counts a wind farm as complete when all of it is. <strong>In development</strong> means the project hasn't yet shown LCCC it is committed to building, usually by spending a set share of its costs; most round 6 and 7 contracts are here, as is Hinkley Point C. <strong>Terminated</strong> contracts were given up: Ørsted dropped Hornsea Project Four and Vattenfall dropped Norfolk Boreas when construction costs rose above what their strike prices would cover.</p>`viewof notStarted = Inputs.radio([...WAITING, "Started, no payments yet", "In development", "Terminated", "All"],
{value: "Complete, CfD not started", label: "Show"})notRows = U.filter(r => notStarted === "All" || r.status === notStarted).map(r => {
const done = r.complete ? new Date(r.complete) : null, start = r.start ? new Date(r.start) : null;
return {
name: r.name + (r.approx ? " *" : ""), tech: r.tech, round: r.round, mw: r.mw, status: r.status,
complete: done, since: done ? monthsBetween(done, latest) : null, start,
wait: done && start ? monthsBetween(done, start) : null, delivery: r.delivery, strike: r.strike
};
})html`<div class="cfd-plain-table">${Inputs.table(notRows, {
columns: ["name", "tech", "round", "mw", "status", "complete", "since", "start", "wait", "delivery", "strike"],
header: {name: "Contract", tech: "Technology", round: "Round", mw: "MW", status: "Status", complete: "Complete",
since: "Months since complete", start: "Expected CfD start", wait: "Months complete before CfD",
delivery: "Delivery year bid", strike: "Strike £/MWh today"},
format: {
mw: v => fmt.format(v), delivery: v => v ?? "—",
complete: d => d ? d.toLocaleDateString("en-GB", {month: "short", year: "numeric", timeZone: "UTC"}) : "—",
start: d => d ? d.toLocaleDateString("en-GB", {month: "short", year: "numeric", timeZone: "UTC"}) : "—",
since: v => v ?? "—", wait: v => v ?? "—", strike: v => v == null ? "—" : v.toFixed(0)
},
width: {name: 200}, layout: "auto", rows: 14,
sort: notStarted === "Complete, CfD not started" ? "since" : "mw", reverse: true
})}</div>`html`<p class="cfd-note" style="margin-top:8px">Contracts and expected start dates from LCCC's CfD contract portfolio status. Completion dates from DESNZ's Renewable Energy Planning Database (${D.repd_edition}), which records when a whole project became operational, so every phase of a phased wind farm shares one date. "Months since complete" runs to ${latestLong}, the date of LCCC's latest data; "months complete before CfD" runs to the expected CfD start. Strike prices are from the auction results, in today's money (CPI); a * marks one estimated from the round and technology average. ${fmt.format(statusOf("Build stage not known").n)} waiting contracts couldn't be matched to the planning database with confidence, and ${fmt.format(statusOf("Started, no payments yet").n)} have started so recently that no payments have been settled yet.</p>`Notes on the data
- Net payments are LCCC’s CfD difference payments: positive when the generator is topped up to its strike price, negative when it pays back. Bill payers fund them through a levy on electricity suppliers.
- Cost at strike is each day’s generation times that day’s strike price: what the power cost in all, of which the market value was paid through the wholesale market and the rest is the net payment.
- Market price is the market reference price each contract is paid against, weighted by its daily output. Wind and solar use the day-ahead price (IMRP); biomass and the other baseload contracts use a seasonal baseload price (BMRP).
- Strike prices rise each April with CPI. Today’s money scales every month’s pounds by CPI (ONS series D7BT) to the latest month published; generation is unaffected.
- Data is daily, summed to months. The latest year is part-year, to .
- Built from the same data as the CfD cost comparison, and refreshed when the site is rebuilt (
cfd-dashboard/prepare.py).