Skip to main content

Negative prices

A quantity that crosses zero needs a chart that takes zero seriously. Bars above and below a shared baseline do it: the sign is the direction, the magnitude is the length, and the zero line is a real feature of the plot rather than the bottom of it.

The chart

Eight of the 72 hours hang below the line. The deepest is −52.42 EUR/MWh at 13:00 on Easter Sunday — and seven hours later, the same day cleared at +144.95. Both halves are live: hover a bar and the crosshair prints its price against the axis (−52, 145 — the pill shares the axis's ,.0f tick format, so it rounds), and because the layer carries an id, a bar lights on hover and outlines on click, below the zero line as readily as above it. The time pill reads the auction hour's midpointApr 20, 13:30 for the 13:00–14:00 hour — because the bar is keyed by a span, not an instant.

The data

Real measured data: the DE-LU day-ahead auction price, one row per hour, over the same Easter weekend 2025 as the other two Energy cards. From energy-charts.info (Fraunhofer ISE), CC BY 4.0, upstream Bundesnetzagentur | SMARD.de.

ColumnMeaning
timeRangeThe auction hour, as a span — { start, end }, not an instant
priceEUR/MWh; negative is normal, not an error

Negative prices are a real market outcome, not bad data. When must-run generation exceeds demand, sellers pay to deliver rather than shut down plant they can't cheaply restart. This weekend is a textbook case: the eight negative hours begin Saturday 13:00, 14:00 and 15:00 and Sunday 11:00 through 15:00, and the Sunday run brackets the 1 h 45 m when wind and solar out-produced total load — it opens at 11:00, an hour and a quarter before the crossing begins at 12:15, and its last hour still starts at 15:00, an hour and a quarter after the crossing ends at 13:45. The auction prices the whole surplus, not only the quarter-hours that cleared 100%. The weekend's mean was +72.06 EUR/MWh, so the negative hours are the exception the chart exists to show.

The key is a timeRange, not a time. A day-ahead price is a span — it clears for the hour — and a span-keyed series is also what gives <BarChart> a width to draw. An instant-keyed row would collapse to the theme's minWidth.

The time axis renders in your browser's timezone; clock times here are CEST.

Build it

1. Key the series by the hour, not the instant.

const schema = [
{ name: 'timeRange', kind: 'timeRange' },
{ name: 'price', kind: 'number' },
] as const;

const prices = new TimeSeries({
name: 'de-lu-day-ahead',
schema,
rows: PRICE_EUR_MWH.map((price, i) => [
{ start: T0 + i * HOUR, end: T0 + (i + 1) * HOUR },
price,
]),
});

2. Draw it. There is no baseline prop to set. <BarChart> always pulls 0 into the auto-fitted domain (barExtent) and resolves each bar's baseline as zero clamped into that domain (resolveBarBaseline), so a series that straddles zero gets the zero line on screen and negative bars drawn downward for free:

<Layers>
<BarChart series={prices} column="price" axis="eur" gap={2} />
</Layers>

That is already the finished shape. Everything below is emphasis.

3. Colour by sign with binColors. It's the per-bar colour channel — binColors[i] fills bar i (the stacked chart's colors is per-group instead). Derive it from the series' own data:

const price = prices.column('price');
const binColors = Array.from({ length: prices.length }, (_, i) =>
(price.at(i) ?? 0) < 0 ? negative : positive,
);

The two colours come off the live theme, not out of a literal, so they flip with the site's dark mode:

const positive = theme.bar.default.fill;
const negative = theme.candle.default.falling.body;

Reaching into the candle role is deliberate. The palette keeps one red/green pair as its "conventional exception" for signed quantities, and a price below zero is exactly that. There is no dedicated diverging bar role yet — if you find yourself wanting one, that's a theme role to add, not a hex literal to inline.

Options to try

OptionWhat it doesReach for it when
binColorsPer-bar fill, aligned to the bars in orderThe sign, a threshold breach, or a zone matters more than the series
gapPixels between adjacent barsDense hourly data; drop it to 0 and the bars read as a column block
<YAxis min> / maxPins the domain instead of auto-fittingComparing two periods that must share a scale
cursor="crosshair"Reticle + value pill on the bar under the pointerReading a single hour off the chart without a click
idTurns on hit-testing — hover lights, click outlinesAny bar chart you want to feel touchable
id + onSelectReports the click as { id, key, value, label }Drilling from an hour into what set it
decimateEnvelope-draws bars once slots fall under ~1 pxA year of hourly prices; note binColors turns it off

An explicit <YAxis min> above zero is the one thing to avoid here: the bars then rest on the axis floor rather than the zero line, and a negative price would simply be off-screen.

See also

  • <BarChart> — every prop, including the stacked and horizontal forms
  • ThemingBarStyle, and where binColors sits relative to roles
  • Renewables vs demand — the surplus that caused these hours
  • Grid mix — what was actually generating at the time
  • Storybook — the systematic knob walk