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 midpoint — Apr 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.
| Column | Meaning |
|---|---|
timeRange | The auction hour, as a span — { start, end }, not an instant |
price | EUR/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
| Option | What it does | Reach for it when |
|---|---|---|
binColors | Per-bar fill, aligned to the bars in order | The sign, a threshold breach, or a zone matters more than the series |
gap | Pixels between adjacent bars | Dense hourly data; drop it to 0 and the bars read as a column block |
<YAxis min> / max | Pins the domain instead of auto-fitting | Comparing two periods that must share a scale |
cursor="crosshair" | Reticle + value pill on the bar under the pointer | Reading a single hour off the chart without a click |
id | Turns on hit-testing — hover lights, click outlines | Any bar chart you want to feel touchable |
id + onSelect | Reports the click as { id, key, value, label } | Drilling from an hour into what set it |
decimate | Envelope-draws bars once slots fall under ~1 px | A 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- Theming —
BarStyle, and wherebinColorssits 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