src/nordpool.rs
//! **Nord Pool** day-ahead electricity spot prices — the Nord Pool data-portal
//! day-ahead document (`.../api/DayAheadPrices?...`). The document is **nested by
//! period, then keyed by bidding zone**: each `multiAreaEntries[]` element carries
//! a `deliveryStart`/`deliveryEnd` window and an `entryPerArea` MAP whose keys are
//! the bidding zones (`SE1`..`SE4`, `NO1`.., `DK1`, `FI`, …) and whose values are
//! the hourly prices. Those variable per-area keys are why this needs a dedicated
//! flattener rather than the generic REST field-map adapter: [`parse_nordpool_json`]
//! FANS the period × area matrix OUT to one flat row per `(area, period)`.
//!
//! Nord Pool prices are **licensed** (paid / data agreement) — this collector is
//! declared [`GdprClass::Gated`] / [`AuthKind::Licensed`] and is default-OFF for
//! outward redistribution; the parse itself is pure-Rust, offline-testable.
//!
//! Offline contract: [`parse_nordpool_json`] turns a canned day-ahead document
//! into per-`(area, period)` rows (area, delivery_start, delivery_end, price,
//! currency) → Arrow.
use anyhow::Result;
use arrow_array::RecordBatch;
use arrow_schema::{DataType, Schema};
use serde_json::Value;
use super::arrow_util::{batch, f64_col, schema, str_col};
use super::{
AuthKind, BatchSink, Bounds, Checkpoint, CollectStats, Collector, CollectorCfg, CollectorMeta,
CollectorPath, Direction, GdprClass, SourceEndpoint, SourceKind, SourceTarget,
};
/// One flattened Nord Pool spot price: a single bidding zone's price for a single
/// delivery period.
#[derive(Debug, Clone, PartialEq)]
pub struct NordPoolPrice {
/// Bidding zone / delivery area (`"SE3"`, `"NO1"`, `"DK1"`, `"FI"`, …).
pub area: String,
/// Delivery-period start (ISO-8601 — the CDC / partition key).
pub delivery_start: Option<String>,
/// Delivery-period end (ISO-8601).
pub delivery_end: Option<String>,
/// Price for this area in this period (document `currency` per MWh).
pub price: f64,
/// Document-level currency (`"EUR"`, `"SEK"`, …).
pub currency: Option<String>,
}
/// Flatten a Nord Pool day-ahead document → per-`(area, period)` price rows.
///
/// Accepts either the modern data-portal shape (`multiAreaEntries[]` with an
/// `entryPerArea` map) or a legacy `areas[]` shape (each area carrying its own
/// `deliveryStart/deliveryEnd/price`). A period with no numeric prices contributes
/// no rows (the collector turns an all-empty document into a RAGNARÖK `reason`).
pub fn parse_nordpool_json(json: &Value) -> Result<Vec<NordPoolPrice>> {
let currency = json
.get("currency")
.and_then(|v| v.as_str())
.map(str::to_string);
let mut out = Vec::new();
// Modern shape: multiAreaEntries[] → { deliveryStart, deliveryEnd, entryPerArea{area:price} }.
if let Some(entries) = json.get("multiAreaEntries").and_then(|v| v.as_array()) {
for e in entries {
let ds = e
.get("deliveryStart")
.and_then(|v| v.as_str())
.map(str::to_string);
let de = e
.get("deliveryEnd")
.and_then(|v| v.as_str())
.map(str::to_string);
if let Some(per_area) = e.get("entryPerArea").and_then(|v| v.as_object()) {
for (area, price_v) in per_area {
if let Some(price) = as_price(price_v) {
out.push(NordPoolPrice {
area: area.clone(),
delivery_start: ds.clone(),
delivery_end: de.clone(),
price,
currency: currency.clone(),
});
}
}
}
}
return Ok(out);
}
// Legacy shape: areas[] → { area/areaCode, deliveryStart, deliveryEnd, price }.
if let Some(areas) = json.get("areas").and_then(|v| v.as_array()) {
for a in areas {
let area = a
.get("area")
.or_else(|| a.get("areaCode"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let ds = a
.get("deliveryStart")
.and_then(|v| v.as_str())
.map(str::to_string);
let de = a
.get("deliveryEnd")
.and_then(|v| v.as_str())
.map(str::to_string);
if let Some(price) = a.get("price").and_then(as_price) {
out.push(NordPoolPrice {
area,
delivery_start: ds,
delivery_end: de,
price,
currency: currency.clone(),
});
}
}
}
Ok(out)
}
/// A price value may arrive as a JSON number or a string (some feeds quote it, and
/// EU locales occasionally use a decimal comma) — coerce both, reject others.
fn as_price(v: &Value) -> Option<f64> {
v.as_f64().or_else(|| {
v.as_str()
.and_then(|s| s.trim().replace(',', ".").parse().ok())
})
}
/// Nord Pool prices → one Arrow `RecordBatch`.
pub fn prices_batch(prices: &[NordPoolPrice]) -> Result<RecordBatch> {
let cols = vec![
str_col(
&prices
.iter()
.map(|p| Some(p.area.clone()))
.collect::<Vec<_>>(),
),
str_col(
&prices
.iter()
.map(|p| p.delivery_start.clone())
.collect::<Vec<_>>(),
),
str_col(
&prices
.iter()
.map(|p| p.delivery_end.clone())
.collect::<Vec<_>>(),
),
f64_col(&prices.iter().map(|p| Some(p.price)).collect::<Vec<_>>()),
str_col(
&prices
.iter()
.map(|p| p.currency.clone())
.collect::<Vec<_>>(),
),
];
batch(&nordpool_schema(), cols)
}
pub fn nordpool_schema() -> Schema {
schema(&[
("area", DataType::Utf8, false),
("delivery_start", DataType::Utf8, true),
("delivery_end", DataType::Utf8, true),
("price", DataType::Float64, false),
("currency", DataType::Utf8, true),
])
}
/// The Nord Pool day-ahead spot-price collector.
#[derive(Default)]
pub struct NordPoolCollector;
#[async_trait::async_trait]
impl Collector for NordPoolCollector {
/// **Nord Pool day-ahead prices are LICENSED.** The public data portal serves a
/// slice without credentials, but the collector's declared endpoint is the
/// licensed API, so it declares the licence credential by name: an operator who
/// has none must see WHY, not a 403 rendered as a parse failure.
fn requirements(&self) -> crate::requires::Requirements {
crate::requires::Requirements::open(self.source_target().source_endpoint).with_secrets(
vec![crate::requires::SecretReq::new(
"NORDPOOL_API_TOKEN",
"a Nord Pool data licence token (paid; the collector is default-OFF)",
)],
)
}
/// **The card that has to say "paid" plainly.** Nord Pool is the one source in this
/// family where an operator can wait forever for a free key that does not exist, so
/// [`Registration::Agreement`](crate::human_doc::Registration::Agreement) names the
/// agreement, the address that starts it, and the fact that credentials arrive by SMS.
///
/// Read off Nord Pool's own developer portal on 2026-09-04 (*Introduction to Nord
/// Pool Market Data API*, *Authentication and access to Market Data API*, *Rate
/// limits for Market Data API*, *15-minute MTU in Market Data API*). The two 2026-09-04
/// MEASUREMENTS below were taken against the live data portal from this machine.
fn human_doc(&self) -> Option<crate::human_doc::HumanDoc> {
use crate::human_doc::{Contact, HumanDoc, RateLimit, Registration};
Some(
HumanDoc::new(
"Nord Pool AS — the Nordic/Baltic power exchange, part of the Euronext group",
"Nord Pool AS: the day-ahead auction results are the exchange's OWN market \
data, which is why they are licensed rather than open — nobody upstream can \
grant you a re-use right to them",
"https://www.nordpoolgroup.com/globalassets/services/2026-euronext-group-data-agreement-master-terms-and-nord-pool-product-terms.zip",
"https://developers.nordpoolgroup.com/reference/marketdata-introduction",
)
.with_contact(Contact::Email("pds@nordpoolgroup.com".into()))
.with_registration(Registration::Agreement {
how: "a PAID Nord Pool / Euronext data agreement. Email pds@nordpoolgroup.com \
with \"Market Data API\" in the subject line to start it; credentials \
arrive by SMS and are exchanged for a bearer token at \
https://sts.nordpoolgroup.com/connect/token. There is no free tier and no \
self-service key — do not wait for one."
.into(),
})
.with_rate_limit(RateLimit::Documented(
"~1 request/second per endpoint, issued SEQUENTIALLY; the gateway allows \
bursts over a rolling 10-second window of 100 on /api/v2/*, 50 on \
/api/v2/Intraday, and 25 on /api/v2/Intraday/OrderBook and \
/api/v2/HubToHubCapacities"
.into(),
))
.with_quirk(
"The endpoint this collector declares — `dataportal-api.nordpoolgroup.com` — \
is the BACKEND OF THE PUBLIC DATA PORTAL, not the licensed Market Data API \
(`data-api.nordpoolgroup.com`) the documentation above describes. Measured \
2026-09-04: with no credential at all, \
`…/api/DayAheadPrices?date=2026-09-03&market=DayAhead&deliveryArea=SE3¤cy=EUR` \
answered HTTP 200 with a real `multiAreaEntries` document. It is undocumented \
and unsupported; the agreement buys the documented API, not this one.",
)
.with_quirk(
"A bare `…/api/DayAheadPrices` with NO query string answers HTTP 401 (measured \
2026-09-04). That 401 is a missing-PARAMETER error, not an auth error — do \
not go hunting for a token because of it. Supply `date`, `market`, \
`deliveryArea` and `currency` first.",
)
.with_quirk(
"Since the switch to a 15-minute Market Time Unit, Day-Ahead \
`deliveryStart`/`deliveryEnd` bound 15-MINUTE intervals — four rows per zone \
per hour, not one. Measured 2026-09-04 on SE3: 22:00→22:15. Anything \
downstream that assumes 24 rows per zone per day is wrong by 4×. (Nord Pool \
states UK / N2EX is not affected.)",
)
.with_quirk(
"The licence is not a web page: it is a ZIP of PDFs (the Euronext group data \
agreement master terms plus the Nord Pool product terms), and its filename \
carries the year — expect the terms URL above to move when the year rolls.",
),
)
}
fn meta(&self) -> CollectorMeta {
CollectorMeta {
name: "nordpool".into(),
source_kind: SourceKind::Rest,
path: CollectorPath::RustNative,
direction: Direction::Source,
formats: vec!["json".into()],
license: "Nord Pool data license (paid/agreement; redistribution-gated)".into(),
gdpr: GdprClass::Gated,
auth: AuthKind::Licensed,
join_keys: vec!["area".into(), "delivery_start".into()],
description: "Nord Pool day-ahead electricity spot prices — multiAreaEntries \
period×zone matrix flattened to per-(area, period) price rows \
(SE1–SE4 / NO / DK / FI bidding zones)."
.into(),
}
}
fn source_target(&self) -> SourceTarget {
SourceTarget::warehouse(
SourceEndpoint::NordPool,
"https://dataportal-api.nordpoolgroup.com/api/DayAheadPrices",
"nordpool_prices",
)
}
fn schema(&self, _cfg: &CollectorCfg) -> Result<Schema> {
Ok(nordpool_schema())
}
async fn collect(
&self,
cfg: &CollectorCfg,
_bounds: Bounds,
sink: &BatchSink,
) -> Result<CollectStats> {
let http = cfg.http(10);
let auth = cfg.auth.resolve_async().await?;
let body = http.get_text(&cfg.base_url, auth.as_ref()).await?;
let json: Value = serde_json::from_str(&body)?;
let prices = parse_nordpool_json(&json)?;
if prices.is_empty() {
return Ok(CollectStats {
reason: Some(
"Nord Pool document carried no per-area prices (window empty?)".into(),
),
..Default::default()
});
}
let n = prices.len() as u64;
sink.push(prices_batch(&prices)?)?;
Ok(CollectStats {
rows: n,
batches: 1,
table: cfg.table.clone(),
reason: None,
})
}
async fn checkpoint(&self) -> Result<Option<Checkpoint>> {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A Nord Pool `multiAreaEntries` day-ahead document flattens to one row per
/// `(area, period)`; the document currency rides every row. Offline DATA
/// contract (no network) + a testmatrix cell.
#[test]
fn nordpool_multiarea_document_flattens_to_area_period_rows() {
let json: Value = serde_json::json!({
"deliveryDateCET": "2026-07-13",
"currency": "EUR",
"multiAreaEntries": [
{
"deliveryStart": "2026-07-13T00:00:00Z",
"deliveryEnd": "2026-07-13T01:00:00Z",
"entryPerArea": { "SE1": 42.5, "SE3": 55.1, "SE4": 60.2 }
},
{
"deliveryStart": "2026-07-13T01:00:00Z",
"deliveryEnd": "2026-07-13T02:00:00Z",
"entryPerArea": { "SE1": 40.0, "SE3": 50.0, "SE4": 58.0 }
}
]
});
let prices = parse_nordpool_json(&json).expect("parse nordpool");
assert_eq!(prices.len(), 6, "2 periods × 3 zones = 6 flattened rows");
// Every row carries the document currency and a delivery window.
assert!(prices.iter().all(|p| p.currency.as_deref() == Some("EUR")));
assert!(prices.iter().all(|p| p.delivery_start.is_some()));
// A specific (area, period) → price lookup.
let se3_h0 = prices
.iter()
.find(|p| {
p.area == "SE3" && p.delivery_start.as_deref() == Some("2026-07-13T00:00:00Z")
})
.expect("SE3 hour 0 present");
assert!((se3_h0.price - 55.1).abs() < 1e-9, "SE3 hour-0 spot price");
let rb = prices_batch(&prices).expect("prices batch");
assert_eq!(rb.num_rows(), 6);
let schema = rb.schema();
let cols: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
for want in [
"area",
"delivery_start",
"delivery_end",
"price",
"currency",
] {
assert!(
cols.contains(&want),
"price table carries `{want}`: {cols:?}"
);
}
let area = rb
.column_by_name("area")
.and_then(|a| a.as_any().downcast_ref::<arrow_array::StringArray>())
.expect("area is utf8");
let price = rb
.column_by_name("price")
.and_then(|a| a.as_any().downcast_ref::<arrow_array::Float64Array>())
.expect("price is f64");
// Row order is period-major, then map iteration order per period.
assert_eq!(
area.value(0),
"SE1",
"first row is the first area of period 0"
);
assert!((price.value(0) - 42.5).abs() < 1e-9, "SE1 hour-0 price");
crate::functional_status(
"korp-collectors/nordpool",
"multiarea_flatten",
true,
&format!(
"{} (area, period) spot-price rows from multiAreaEntries",
rb.num_rows()
),
);
}
/// The legacy `areas[]` shape (each area self-contained) also flattens, and a
/// string-quoted / decimal-comma price is coerced.
#[test]
fn nordpool_legacy_areas_shape_and_price_coercion() {
let json: Value = serde_json::json!({
"currency": "SEK",
"areas": [
{ "area": "SE3", "deliveryStart": "2026-07-13T00:00:00Z",
"deliveryEnd": "2026-07-13T01:00:00Z", "price": "612,34" },
{ "areaCode": "SE4", "deliveryStart": "2026-07-13T00:00:00Z",
"deliveryEnd": "2026-07-13T01:00:00Z", "price": 700.0 }
]
});
let prices = parse_nordpool_json(&json).expect("parse legacy");
assert_eq!(prices.len(), 2, "two legacy area rows");
let se3 = prices.iter().find(|p| p.area == "SE3").expect("SE3");
assert!(
(se3.price - 612.34).abs() < 1e-9,
"decimal-comma string price coerced"
);
let se4 = prices
.iter()
.find(|p| p.area == "SE4")
.expect("SE4 via areaCode");
assert!((se4.price - 700.0).abs() < 1e-9);
assert_eq!(se4.currency.as_deref(), Some("SEK"));
crate::functional_status(
"korp-collectors/nordpool",
"legacy_areas_and_coercion",
true,
"legacy areas[] shape + string/decimal-comma price coercion",
);
}
/// A document with no numeric prices yields zero rows (the collector maps that
/// to a RAGNARÖK reason) — the RED-when-broken empty guard.
#[test]
fn nordpool_empty_document_yields_no_rows() {
let json: Value = serde_json::json!({ "currency": "EUR", "multiAreaEntries": [] });
assert!(parse_nordpool_json(&json).expect("parse").is_empty());
// A period whose prices are all non-numeric also contributes nothing.
let json2: Value = serde_json::json!({
"currency": "EUR",
"multiAreaEntries": [
{ "deliveryStart": "x", "deliveryEnd": "y", "entryPerArea": { "SE3": null } }
]
});
assert!(parse_nordpool_json(&json2).expect("parse").is_empty());
}
}