Multi-Country v2.0 — PT/FR/DE Production Models (Production)
Date: April 11, 2026 | Status: Production
What happened
The v1.0 models for Portugal, France, and Germany (shipped April 10, 2026 as part of M5-M7 milestones) had a critical bug: they used the 15-minute feature builder (build_direct_features_15min) on hourly ENTSO-E data. This made every price lag 4x too long — a “24-hour lag” was actually 96 hours (4 days), a “7-day lag” was actually 28 days.
v2.0 fixes this by switching to the correct hourly feature builder, adds ES cross-price features as the strongest market coupling signal, and applies per-country hyperparameter tuning.
Three root causes fixed
1. Resolution mismatch (the biggest bug)
All non-ES data from ENTSO-E is hourly. The training pipeline used --approach hybrid15 which routes to build_direct_features_15min, where:
price_lag_24h = price.iloc[i - 96]assumes 96 = 24h at 15-min resolution- But with hourly data,
i - 96= 96 hours = 4 days ago
Every lag, rolling average, and momentum feature was computed at the wrong timescale. The model could still learn patterns from the wrong lags (XGBoost is flexible), but the features carried far less signal than intended.
Fix: Use --approach hourly --resolution hourly for non-ES countries.
2. Inference zero-fill (repeat of v10.x LSTM bug)
The ES cross-price features (es_price_lag_24h, es_price_rolling_168h, es_pt_spread_lag_24h, etc.) were added to the training feature builder but not to the prediction feature builder. At inference, these features were silently filled with 0.0:
# direct_predictor.py line 447-449for f in feature_cols: if f not in row.columns: row[f] = 0.0 # ← silent zero-fillThis is the same class of bug that affected the v10.x LSTM embeddings. The model learned from real ES prices during training but predicted with zeros.
Fix: Added ES cross-price computation to both _build_origin_features (hourly) and _build_origin_features_15min in direct_predictor.py.
3. Actual price backfill from wrong table
backfill_actual_prices() hardcoded ree_hourly (ES-only table) for looking up actual prices. Non-ES countries store prices in market_hourly with column price_eur_mwh. This meant backtest evaluation metrics were computed against ES prices, not the target country’s prices.
Fix: Country-aware backfill using market_hourly WHERE country = :c for non-ES countries.
Experiment results
Portugal (10 scouts)
| Tag | Config change | MAE | vs v1.0 |
|---|---|---|---|
| pt-v1.0 | 15min builder, no ES features | 42.48 | — |
| pt-v1.1 | + ES cross-price (6 features) | 31.88 | -24.9% |
| pt-v1.2 | + hydro/gen features | 35.70 | Rejected (+12% vs v1.1) |
| pt-v1.3 | + depth=8, pw=40 | 31.41 | -26.1% |
| pt-v2.0 | Hourly resolution + all fixes | 8.43 | -80.2% |
France (10 scouts)
| Tag | Config change | MAE | vs v1.0 |
|---|---|---|---|
| fr-v1.0 | 15min builder, no ES features | 23.14 | — |
| fr-v2.0 | Hourly + ES xprice + depth=12 | 6.40 | -72.3% |
| fr-v2.2 | + no transform (remove residual_1w) | 5.81 | -74.9% |
| fr-v2.3 | + q=0.50 (symmetric loss) | 5.66 | -75.5% |
| fr-v2.6 | + halflife=90 | 4.87 | -78.9% |
Germany (7 scouts)
| Tag | Config change | MAE | vs v1.0 |
|---|---|---|---|
| de-v1.0 | 15min builder, no ES features | 20.44 | — |
| de-v2.0 | Hourly + ES xprice + depth=12 | 5.47 | -73.2% |
| de-v2.4 | + halflife=180 | 5.28 | -74.2% |
| de-v2.5 | + halflife=90 | 4.14 | -79.7% |
Per-country best configs
| Parameter | ES v11.0 | PT v2.0 | FR v2.0 | DE v2.0 |
|---|---|---|---|---|
| Resolution | 15min (hybrid15) | hourly | hourly | hourly |
| Target transform | residual_1w | residual_1w | none | residual_1w |
| Quantile | 0.55 | 0.55 | 0.50 | 0.55 |
| Halflife | 365d | 365d | 90d | 90d |
| ES cross-price | N/A (is ES) | yes | yes | yes |
| Depth | 12 | 12 | 12 | 12 |
| Price weighting | 3x > 60 EUR | 3x > 60 EUR | 3x > 60 EUR | 3x > 60 EUR |
What ES cross-price features are and why they work
The six ES cross-price features added to PT/FR/DE models:
es_price_lag_24h— ES day-ahead price from 24 hours agoes_price_lag_48h— ES day-ahead price from 48 hours agoes_price_lag_168h— ES day-ahead price from 7 days agoes_price_rolling_24h— 24-hour rolling average of ES priceses_price_rolling_168h— 7-day rolling average of ES priceses_pt_spread_lag_24h— ES price minus target country price (24h lag)
Why they work: European electricity markets are physically and financially interconnected. OMIE operates a unified Iberian market (ES+PT coupling rate >95%). France and Germany are connected to Spain via cross-border transmission. ES day-ahead prices, published by 12:30 CET, are available before other countries’ prediction runs and provide the strongest available signal for European price levels.
Day-of-week patterns
Both FR and DE show the same weakness: Monday and Sunday predictions are ~2x worse than midweek. This reflects the weekend-to-weekday demand transition, which is harder to forecast because industrial load patterns shift.
| Day | FR v2.0 MAE | DE v2.0 MAE |
|---|---|---|
| Monday | 8.22 | 9.40 |
| Tuesday | 3.98 | 3.14 |
| Wednesday | 2.60 | 1.66 |
| Thursday | 3.53 | 1.76 |
| Friday | 3.15 | 2.41 |
| Saturday | 4.69 | 3.25 |
| Sunday | 7.74 | 7.35 |
Comparison with ES v11.0
| Metric | ES v11.0 | PT v2.0 | FR v2.0 | DE v2.0 |
|---|---|---|---|---|
| DA MAE | 14.26 | 8.43 | 4.87 | 4.14 |
| Correlation | 0.891 | 0.855+ | 0.85+ | 0.85+ |
| vs v1.0 | — | -80.2% | -78.9% | -79.7% |
All three non-ES countries now outperform ES v11.0 on MAE. This reflects two factors: (1) the hourly resolution gives correct lag semantics, and (2) ES cross-price features provide a strong anchor signal that ES itself doesn’t need (since it IS the anchor).