Local 15‑minute Finnish electricity spot price API (ENTSO‑E + Elering fallback) for Home Assistant & energy automations.
This project is a lightweight Node.js / TypeScript server that collects and normalizes Finnish day-ahead electricity spot prices at 15‑minute resolution. It queries the ENTSO‑E Transparency Platform (documentType A44) and, if data remains incomplete after mid‑afternoon, falls back to the Elering public API to fill gaps. Results are cached in-memory and persisted to a tiny SQLite database to survive restarts.
Use cases:
- Powering Home Assistant REST sensors & automations (shift loads to cheapest consecutive hours).
- Evaluating off‑peak / peak averages and tomorrow’s forecast once published.
- Adding transfer (distribution + taxes) components to compute effective delivered price blocks.
Design highlights:
- Deterministic expansion to 15‑minute slots even across DST (23/24/25 hour) days.
- Atomic price refresh guarded by a mutex; incremental persistence on cache updates.
- Query engine for best/worst price windows with weighting modes.
- Simple zero-dependency HTTP server (native
httpmodule) for minimal footprint.
- Startup (
src/spot.ts):- Validates presence of
ENTSOE_SECURITY_TOKENenvironment variable. - Initializes SQLite table and warms NodeCache from DB.
- Triggers initial fetch/update.
- Validates presence of
- Periodic updates:
- Every minute:
rootController.updatePrices()checks for missing time slots (yesterday, today, and (after 14:00) tomorrow’s hours). If missing, fetch window (−2h … +2h) from ENTSO-E. - If ENTSO-E response still incomplete after 15:00, attempts fallback to Elering.
- Every minute:
- Cache persistence:
- On any cache set,
storeController.updateStoredResultWhenChanged()merges with DB and persists de‑duplicated, sorted rows (−2 days to tomorrow 24:00).
- On any cache set,
- Midnight:
- Flush cache, re-hydrate from DB, refresh prices.
ENTSO-E A44 returns EUR/MWh. The utils.getPrice() function converts to EUR/kWh dividing by 1000 and applies VAT multiplier (constant VAT = 1.255) to positive prices only. Negative prices are kept without VAT increase.
- Off-peak today: 22:00 (yesterday) → 07:00 (today)
- Peak today: 07:00 → 22:00 (today)
- Off-peak tomorrow: 22:00 (today) → 07:00 (tomorrow)
- Peak tomorrow: 07:00 → 22:00 (tomorrow)
All modes operate on a contiguous window of N hours (each hour = 4×15min slots).
- LowestAverage: Minimizes simple sum (uniform weights) of prices in the block.
- LowestWeighted: Minimizes weighted sum where earlier slots have higher weight (prefers earlier cheap periods).
- HighestAverage: Maximizes simple sum (finds most expensive block).
When
offPeakTransferPriceandpeakTransferPriceare provided, each slot receives an additive transfer component based on the local hour (22–07 off-peak). Computed stats (min/max/avg) are returned both raw and with transfer.
Base URL: http://<host>:8089
/(Root) – Returns aPricesContainer:
{
"info": {
"current": "0.01567",
"averageToday": "0.01268",
"averageTodayOffPeak": "0.00552",
"averageTodayPeak": "0.01701",
"tomorrowAvailable": true,
"averageTomorrow": "0.05151",
"averageTomorrowOffPeak": "0.00617",
"averageTomorrowPeak": "0.07305"
},
"today": [ { "start": "2025-10-22T00:15:00.000+03:00", "price": "0.00294" }, ... ],
"tomorrow": [ { "start": "2025-10-23T00:15:00.000+03:00", "price": "0.00627" }, ... ]
}/query– Parameters:queryMode=LowestAverage|LowestWeighted|HighestAverage(required)hours=INTEGER(1–24) (required)startTime=UNIX_SECONDS(required)endTime=UNIX_SECONDS(required)offPeakTransferPrice=NUMBER(optional)peakTransferPrice=NUMBER(optional) Returns:
{
"hours": { "startTime": "2025-10-22T09:00:00.000+03:00", "endTime": "2025-10-22T15:15:00.000+03:00" },
"info": {
"now": false,
"min": 0.00501,
"max": 0.02455,
"avg": 0.01234,
"withTransferPrices": {
"avg": 0.04567,
"min": 0.0339,
"max": 0.06211
}
}
}(When transfer prices omitted, withTransferPrices object is not included.)
-
/links– Parameters:hours=INTEGER(optional; default 6)offPeakTransferPrice=NUMBER(optional)peakTransferPrice=NUMBER(optional) Returns structure with example/queryURLs for today & tomorrow with and without transfer prices.
-
/reset– Flushes in-memory cache; re-warms from DB. -
/resetAll– Drops DB table, re-creates it, flushes cache (use carefully). Returns plain "Ok".
HTTP Status Codes:
- 200 on success.
- 404 for unknown paths.
Required:
ENTSOE_SECURITY_TOKEN– Personal API token from ENTSO-E Transparency Platform (see their portal under account settings). Optional:TZ– Time zone (docker-compose setsEurope/Helsinki). Must match intended market; logic assumes Finland bidding zone (out_Domain=10YFI-1--------U).
Can be provided via a .env file in project root when running locally:
ENTSOE_SECURITY_TOKEN=your_token_here
Prerequisites: Node.js >= 18
npm install
npx tsc
export ENTSOE_SECURITY_TOKEN="your_token"
node build/spot.jsServer listens on http://localhost:8089.
Build and run directly:
docker build -t spot-prices .
docker run -d --name spot-prices -p 8089:8089 -e ENTSOE_SECURITY_TOKEN=your_token -e TZ=Europe/Helsinki spot-pricesSQLite data directory is symlinked to /data inside container; map a volume for persistence if desired.
docker-compose.yml already provided:
export ENTSOE_SECURITY_TOKEN=your_token
docker compose up -d --buildAdd environment: section variable if not specifying externally:
environment:
- TZ=Europe/Helsinki
- ENTSOE_SECURITY_TOKEN=${ENTSOE_SECURITY_TOKEN}Get root data:
curl http://localhost:8089/Find cheapest 6-hour block between yesterday 21:00 and today 21:00:
START=$(date -v-1d +%s | awk '{print $1 - ($1%3600) + 21*3600}')
END=$(date +%s | awk '{print $1 - ($1%3600) + 21*3600}')
curl "http://localhost:8089/query?queryMode=LowestAverage&hours=6&startTime=$START&endTime=$END"Same with transfer prices:
curl "http://localhost:8089/query?queryMode=LowestWeighted&hours=6&startTime=$START&endTime=$END&offPeakTransferPrice=0.0274&peakTransferPrice=0.0445"Generate example links for tomorrow (once prices are available):
curl "http://localhost:8089/links?hours=8&offPeakTransferPrice=0.0274&peakTransferPrice=0.0445"Create a REST sensor in configuration.yaml to capture current price and averages:
sensor:
- platform: rest
name: Spot Price Current
resource: http://localhost:8089/
value_template: '{{ value_json.info.current }}'
unit_of_measurement: 'EUR/kWh'
scan_interval: 300
- platform: rest
name: Spot Price Avg Today
resource: http://localhost:8089/
value_template: '{{ value_json.info.averageToday }}'
unit_of_measurement: 'EUR/kWh'
- platform: rest
name: Spot Price Avg Today Peak
resource: http://localhost:8089/
value_template: '{{ value_json.info.averageTodayPeak }}'
unit_of_measurement: 'EUR/kWh'For a dynamic cheapest block window, use a template sensor hitting /query (automation could refresh periodStart/periodEnd daily).
storeController retains a rolling window (two days back to tomorrow) of 15‑minute slots in SQLite. Older slots are implicitly dropped when not included in merge set.
- Network timeouts (30s) abort ENTSO-E/Elering requests (AbortController + log).
- Missing or incomplete price sets cause retries each minute until complete.
- Mutex (
async-mutex) ensuresupdatePricesatomicity. - If ENTSO-E never fills missing hours by fallback time (after 15:00), Elering merge attempts proceed; remaining gaps logged and skipped.
All computations use Luxon with Europe/Helsinki. The parser calculates number of quarter slots from explicit timeInterval duration and expands resolutions (PT60M, PT30M, PT15M). DST shifts (23/24/25 hour periods) automatically adjust slot count (92/96/100 slots) so daily averages remain correct.
- No authentication or rate limiting. Intended for local network use only.
- Do not expose publicly without adding auth/restrictions.
- Keep ENTSO-E token private (use environment variable, not hard-coded).
Jest test suite (TypeScript/ts-jest) covers parser, controllers, utilities, and query logic. Run tests:
npm testTime zone forced via script (TZ=Europe/Helsinki jest).
src/spot.ts– Server entry point, cron jobs.src/controller/– Request handlers (rootController,queryController,linksController,storeController).src/services/– Query window logic (query.ts,modes.ts) and example link generator.src/parser/entsoParser.ts– ENTSO-E XML to 15‑min price rows.src/utils/– Date and price utilities.data/– SQLite DB location (symlink inside container).build/– Transpiled JavaScript afternpx tsc.
MIT License (see LICENSE).
This project is not affiliated with ENTSO-E or Elering. Use data responsibly; verify prices before automations with financial impact.
Feel free to open issues or PRs for enhancements.