Denmark Day-Ahead Electricity Price Forecast β v3
Quantile LightGBM models forecasting DK1 (West Denmark) and DK2 (East Denmark) day-ahead spot electricity prices, 2 to 9 days ahead. This is the exact model powering elpriser.org/prognose.
This repo is live: retrained from scratch and re-uploaded daily (14:10 UTC, after the day-ahead auction publishes), so the weights here are always what production is running. Training data comes from the sibling Denmark Power Market dataset plus live weather and ENTSO-E capacity data.
What changed in v3
v3 was built specifically to remove the three limitations v2's model card admitted to. All three are addressed, and the backtest below reports what each change was actually worth rather than asserting that it helped.
1. Interconnector congestion is now modelled (v2: "not modelled"). Two complementary features. Neighbouring-zone weather (NO2, SE3, SE4, NL) at the same forecast leads as Denmark: congestion binds when a price spread pushes flow past what a border can carry, and those spreads are driven by the neighbours' weather β which, unlike congestion data itself, genuinely exists 10 days out. Month-ahead transfer capacity (ENTSO-E A61, contract type A03) covers the capacity side: scheduled derating and maintenance, published a month ahead and therefore leakage-free at every horizon here.
2. Weather is now spatially resolved (v2: "single representative
coordinate per area"). Fourteen points: the offshore wind clusters that dominate
Danish wind (Horns Rev, Anholt, RΓΈdsand), German wind in the north and German
solar in the south, plus the four neighbouring zones. Points enter as individual
features rather than hand-weighted averages. wind_spread β the max-minus-min
across an area's points β is its own signal: a wide spread means a front is
crossing, which is exactly when production forecasts are least reliable.
3. The shape blend now has a regime guard (v2: "assumes the last 4 weeks' intraday pattern is still informative"). See "Post-processing" below.
Task & horizons
| Horizon | What it covers |
|---|---|
| Day 0-1 | Actual published day-ahead prices (passed through, not modelled) |
| Day 2-9 | This model: hourly quantile forecast (P10/P50/P90), DKK/MWh |
Architecture
Per area, three LightGBM quantile regressors (objective=quantile,
Ξ± = 0.1 / 0.5 / 0.9) trained on ~166k rows (2024-03-15 β present, 8 horizons Γ
24 hours), plus small weatherβproduction regressors used to build two of the
features.
The two areas do not use the same feature set, on purpose. The
interconnector-capacity block helps DK1 but measurably hurt DK2 in backtest
(see the ablation). DK1 has four borders to DK2's two, so the same block carries
much less signal in DK2 and mostly adds variance. config.json records which
variant each area uses.
| File | What it is |
|---|---|
dk1_lo/md/hi.txt, dk2_lo/md/hi.txt |
Quantile boosters, LightGBM text format |
wind_estimator_dk1/dk2/de.txt |
Weather β wind+solar production estimators |
calibration_hourly.json |
Conformal band-widening factors per area/horizon |
config.json |
Per-area feature lists, horizons, quantile levels, blend settings |
train_daily.py, dataset.py, fetch_ntc.py, fetch_weather_v3.py |
The exact training, inference and data-collection code |
import lightgbm as lgb
booster = lgb.Booster(model_file="dk1_md.txt")
Features
Roughly 55-60 per row depending on area, computed at a specific horizon h (2-9):
- Calendar β
hour,weekday,month,is_weekend,doy_sin,doy_cos,h - Price history β
lag1/2/3/7/14/21/28(same hour, k days back), populated only when actually known at issue time (k >= h-1; at h=5 you do not yet know lag1-3, and using them would be leakage), plusseasonal4wandlast_day_mean - Weather, per point, at the correct lead β
wind_p0..p3,rad_p0..p3,temp_p0..p3for the area's own points;wind_de0..2,rad_de0..2for Germany;wind_{no2,se3,se4,nl},rad_{...}for neighbours; plus means andwind_spread - Derived production β
est_prod,est_prod_de - Interconnector capacity (DK1 only) β
ntc_{border}_{exp,imp}scaled by each border's own maximum, plusntc_exp_mean,ntc_imp_mean,ntc_min
Weather comes from Open-Meteo's previous-runs API (previous_day{1..7} β
the forecast as issued N days before the delivery hour, not the eventual
actual), matched to the horizon: horizon h uses lead min(h, 7). That archive
starts 2024-03-15, which sets the training window.
Post-processing (apply this yourself β the raw booster is not the whole model)
1. Hybrid shape with a regime guard. The raw model's hour-to-hour curve ranks hours worse than a plain 4-week seasonal profile, so the model's daily level is kept while its intraday shape is blended with the seasonal profile's:
seasonal = mean_price_by(weekday, hour) # last 28 known days
hybrid[hr] = pred.mean() + (1-a)*(pred[hr] - pred.mean()) + a*(seasonal[hr] - seasonal.mean())
a is 0.5 by default. The guard tracks how well the seasonal profile's shape
has tracked reality over the last 14 days versus its own 90-day median (lagged
10 days so nothing inside any horizon's unknown window leaks in). Only when that
ratio exceeds 1.15 does a fall, toward 0 as the profile degrades further.
The guard is deliberately one-directional and conservative, because the alternative was tried and rejected: a freely-fitted blend weight (grid-searched on a held-out recent slice each month) improved MAE but cost min-hour hit-rate in both areas β DK1 69.3% β 67.9% β i.e. it got worse at the one thing the forecast is actually used for. A weight fitted on 30 days of holdout simply does not generalise to the next month. The guard keeps the MAE gain without that cost.
2. Conformal band widening. LightGBM's raw quantile spread covers only
50-55% of outcomes here. 1.7-2.2Γ) fitted so the widened band reaches 80% empirical
coverage:calibration_hourly.json holds per-area, per-horizon
multipliers (
factor = calibration_hourly[area][str(h)]
band_lo = hybrid - factor * (p50 - p10)
band_hi = hybrid + factor * (p90 - p50)
score_area() in train_daily.py is the complete, authoritative implementation.
Performance
Rolling-origin backtest, monthly retrain, tested April 2025 β July 2026, with post-processing applied. Each row adds to the one above it, so the ablation shows what each change was actually worth:
| Variant | DK1 MAE | DK2 MAE |
|---|---|---|
| v2-equivalent baseline | 187.3 | 201.8 |
| + spatial (multi-point weather) | 184.5 (β1.5%) | 197.8 (β2.0%) |
| + neighbouring-zone weather | 181.8 (β3.0%) | 192.2 (β4.8%) |
| + interconnector capacity | 179.5 (β4.2%) | 194.8 (β3.5%) β |
| + regime guard β shipped v3 | 178.4 (β4.8%) | 189.9 (β5.9%) |
β is the reason the configuration is asymmetric: capacity features help DK1 and hurt DK2, so DK2 ships without them (its shipped figure builds on the neighbour-weather row).
| DK1 | DK2 | |
|---|---|---|
| MAE, T+1..2 (% of mean price) | ~25% | ~27% |
| MAE, T+3..9 (% of mean price) | ~30% | ~31% |
| Min-hour hit-rate (cheapest hour in predicted top-3) | 69.0% | 70.0% |
| P10-P90 coverage after calibration | 80.5% | 80.3% |
Baselines on the same backtest: seasonal 4-week heuristic ~35-38% MAE, naive persistence ~41-46%.
These figures are a floor: the backtest deliberately uses a weather-forecast lead at least as old as the operational run would see.
Why this exact configuration (Pareto analysis)
MAE and min-hour hit-rate are genuinely competing objectives here: leaning on
the model's own intraday shape sharpens the price level, leaning on the
seasonal profile ranks the cheap hours better. pareto.py sweeps the whole
achievable set β 4 feature variants Γ 21 blend weights per area β and
pareto_plot.py plots it.
Two results worth stating:
- The shipped configuration sits on the Pareto frontier in both areas. Nothing available is better on one objective without being worse on the other, so there is no free improvement left in this design space.
- v2 is dominated. In DK1 a v3 config reaches ~4.6% lower MAE at the same hit-rate; in DK2 v3 is better on both objectives at once. The v3 changes moved the frontier outward rather than just sliding along it.
Moving along the frontier was tested and deliberately not done. Raising the guard's base weight looks tempting (DK1 hit-rate 69.0% β 69.7%), but a paired bootstrap over 486 days puts the MAE cost firmly outside zero (+1.06 to +1.63 DKK/MWh, 95% CI) while the hit-rate gain barely clears it (+0.05 to +1.49). That is paying something certain for something that may not be there β and picking the best-looking point from the same backtest the numbers are reported from would be selection on the test set. Anyone wanting a different balance should treat it as a product decision and re-validate on fresh data, not tune against this backtest.
Limitations
- Unplanned outages are still invisible. v3 models scheduled capacity (published a month ahead) and the weather-driven price spreads that make congestion bind. A forced trip on an interconnector remains unmodelled and is the largest remaining source of large errors β nothing published far enough ahead can capture it.
- Fourteen points is better than one, but it is still not a grid-weighted field. No generation-capacity weighting is applied; the model infers what each point is worth from data, which needs enough history to do well.
- The regime guard reacts, it does not predict. It needs roughly two weeks of degraded seasonal tracking before it moves, so the first days of a sharp regime change are still forecast with the old assumption.
- The DK1/DK2 asymmetry is empirical, not theoretical. Capacity features hurting DK2 is a single backtest result with a plausible explanation, not an established fact; it should be re-checked as history accumulates.
- Not financial or trading advice. This forecasts a wholesale spot price component, not a household bill, which also includes grid tariffs, taxes and VAT that vary by network operator.
Intended use
Household and consumer decision support β "when this week should I run the dishwasher or charge the car" β as implemented on elpriser.org/prognose. Not validated for wholesale trading, hedging, or any other financial decision.
License
CC BY 4.0, matching the underlying data (Energinet and ENTSO-E, both CC BY 4.0;
Open-Meteo free for commercial and non-commercial use with attribution).
Source: elpriser.org on GitHub,
scripts/forecast_model/v3/.
- Downloads last month
- 34