Skip to content

Commit 055bd82

Browse files
committed
refactor(spot): source prices directly from Elering LIVE
Drop the spot-hinta.fi middleman and read Nord Pool day-ahead straight from the TSO-grade open source (Elering LIVE, keyless). Converts EUR/MWh ex-VAT → c/kWh incl VAT (25.5%) and buckets by Europe/Helsinki (via chrono-tz) so today/tomorrow line up with the local day regardless of server timezone.
1 parent ef9cdad commit 055bd82

6 files changed

Lines changed: 98 additions & 24 deletions

File tree

‎backend/Cargo.lock‎

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎backend/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ tracing = "0.1"
2222
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
2323
rusqlite = { version = "0.40", features = ["bundled"] }
2424
chrono = { version = "0.4", features = ["serde"] }
25+
chrono-tz = "0.10"
2526
quick-xml = "0.40"
2627
sun = "0.3"
2728
hmac = "0.13.0"

‎backend/src/settings.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ impl Settings {
3939
solis_station_id: String::new(),
4040
solis_base_url: "https://www.soliscloud.com:13333".into(),
4141
reserve_provider: "reserve".into(),
42-
spot_base_url: "https://api.spot-hinta.fi".into(),
42+
spot_base_url: "https://dashboard.elering.ee".into(),
4343
}
4444
}
4545

@@ -74,7 +74,7 @@ impl Settings {
7474
.unwrap_or_else(|_| "https://www.soliscloud.com:13333".into()),
7575
reserve_provider: env::var("RESERVE_PROVIDER").unwrap_or_else(|_| "reserve".into()),
7676
spot_base_url: env::var("SPOT_BASE_URL")
77-
.unwrap_or_else(|_| "https://api.spot-hinta.fi".into()),
77+
.unwrap_or_else(|_| "https://dashboard.elering.ee".into()),
7878
}
7979
}
8080
}

‎backend/src/spot/client.rs‎

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,60 @@
11
use std::collections::BTreeMap;
22

3-
use chrono::{DateTime, FixedOffset, NaiveDate, Timelike};
3+
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Timelike, Utc};
4+
use chrono_tz::{Europe::Helsinki, Tz};
45

5-
use super::models::{HourPrice, SpotEntry, SpotResponse};
6+
use super::models::{HourPrice, NpsEntry, NpsResponse, SpotResponse};
67

8+
/// Finnish electricity VAT (25.5%). Elering prices are ex-VAT; we display the
9+
/// consumer price like the price apps do.
10+
const VAT: f64 = 1.255;
11+
12+
/// Fetch today + tomorrow Finnish prices straight from the TSO-grade open source
13+
/// (Elering LIVE, Nord Pool day-ahead). API: <https://dashboard.elering.ee/api>
714
pub async fn fetch(
815
client: &reqwest::Client,
916
base_url: &str,
1017
) -> Result<SpotResponse, reqwest::Error> {
11-
let url = format!("{}/TodayAndDayForward", base_url.trim_end_matches('/'));
12-
let entries: Vec<SpotEntry> = client
13-
.get(&url)
18+
// Window the query to [today 00:00, +2 days) in Helsinki time so the day
19+
// split lines up with the local day, regardless of the server timezone.
20+
let now = Utc::now().with_timezone(&Helsinki);
21+
let start = Helsinki
22+
.with_ymd_and_hms(now.year(), now.month(), now.day(), 0, 0, 0)
23+
.single()
24+
.unwrap_or(now);
25+
let end = start + Duration::days(2);
26+
let iso = |d: DateTime<Tz>| {
27+
d.with_timezone(&Utc)
28+
.format("%Y-%m-%dT%H:%M:%S.000Z")
29+
.to_string()
30+
};
31+
let url = format!(
32+
"{}/api/nps/price?start={}&end={}",
33+
base_url.trim_end_matches('/'),
34+
iso(start),
35+
iso(end),
36+
);
37+
38+
let resp: NpsResponse = client
39+
.get(url)
1440
.send()
1541
.await?
1642
.error_for_status()?
1743
.json()
1844
.await?;
19-
Ok(aggregate(entries))
45+
Ok(aggregate(resp.data.fi))
2046
}
2147

22-
/// Average the 15-min quarters into hourly bars and split into today/tomorrow
23-
/// (the two distinct local dates the upstream returns).
24-
fn aggregate(entries: Vec<SpotEntry>) -> SpotResponse {
48+
/// Average the 15-min entries into hourly bars (in Helsinki local time) and split
49+
/// into today/tomorrow; convert EUR/MWh ex-VAT → c/kWh incl. VAT.
50+
fn aggregate(entries: Vec<NpsEntry>) -> SpotResponse {
2551
// (local date, hour) -> (price sum, count, hour-start datetime)
26-
let mut buckets: BTreeMap<(NaiveDate, u32), (f64, u32, DateTime<FixedOffset>)> =
27-
BTreeMap::new();
52+
let mut buckets: BTreeMap<(NaiveDate, u32), (f64, u32, DateTime<Tz>)> = BTreeMap::new();
2853
for e in &entries {
29-
let Ok(dt) = DateTime::parse_from_rfc3339(&e.date_time) else {
54+
let Some(utc) = Utc.timestamp_opt(e.timestamp, 0).single() else {
3055
continue;
3156
};
57+
let dt = utc.with_timezone(&Helsinki);
3258
let hour_start = dt
3359
.with_minute(0)
3460
.and_then(|d| d.with_second(0))
@@ -37,7 +63,7 @@ fn aggregate(entries: Vec<SpotEntry>) -> SpotResponse {
3763
let slot = buckets
3864
.entry((dt.date_naive(), dt.hour()))
3965
.or_insert((0.0, 0, hour_start));
40-
slot.0 += e.price_with_tax;
66+
slot.0 += e.price;
4167
slot.1 += 1;
4268
}
4369

@@ -53,7 +79,7 @@ fn aggregate(entries: Vec<SpotEntry>) -> SpotResponse {
5379
.filter(|((d, _), _)| *d == target)
5480
.map(|(_, (sum, count, hour_start))| HourPrice {
5581
hour: hour_start.to_rfc3339(),
56-
price: (sum / *count as f64) * 100.0, // €/kWh -> c/kWh
82+
price: (sum / *count as f64) / 10.0 * VAT, // EUR/MWh ex-VAT -> c/kWh incl VAT
5783
})
5884
.collect()
5985
};

‎backend/src/spot/mod.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
//! Finnish spot electricity prices — public, keyless upstream (spot-hinta.fi),
2-
//! fetched directly like the weather sources. 15-min data aggregated to hourly.
1+
//! Finnish spot electricity prices — fetched straight from the TSO-grade open
2+
//! source (Elering LIVE, the Estonian TSO's Nord Pool day-ahead feed), keyless,
3+
//! like the weather sources. EUR/MWh ex-VAT → c/kWh incl VAT, 15-min → hourly.
34
pub mod client;
45
pub mod handlers;
56
pub mod models;

‎backend/src/spot/models.rs‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
use serde::{Deserialize, Serialize};
22
use utoipa::ToSchema;
33

4-
/// Raw entry from spot-hinta.fi `/TodayAndDayForward` (15-min resolution).
4+
/// One entry from Elering LIVE `/api/nps/price` — EUR/MWh ex-VAT, Unix-UTC ts.
55
#[derive(Debug, Deserialize)]
6-
pub struct SpotEntry {
7-
#[serde(rename = "DateTime")]
8-
pub date_time: String, // RFC3339 with local offset, e.g. 2026-06-14T00:15:00+03:00
9-
#[serde(rename = "PriceWithTax", default)]
10-
pub price_with_tax: f64, // €/kWh, incl. VAT
6+
pub struct NpsEntry {
7+
pub timestamp: i64,
8+
pub price: f64,
9+
}
10+
11+
/// `data` object: one array per bidding zone; we read Finland (`fi`).
12+
#[derive(Debug, Default, Deserialize)]
13+
pub struct NpsData {
14+
#[serde(default)]
15+
pub fi: Vec<NpsEntry>,
16+
}
17+
18+
#[derive(Debug, Deserialize)]
19+
pub struct NpsResponse {
20+
#[serde(default)]
21+
pub data: NpsData,
1122
}
1223

1324
/// One hourly bar. `hour` is the local-time hour start (RFC3339); `price` is

0 commit comments

Comments
 (0)