#!/usr/bin/env python3 """First energy gate: is battery arbitrage a real, persistent, engine-suited edge? Free DE-LU day-ahead hourly prices (Fraunhofer Energy-Charts, no key). For a 4h/1MW battery (1 cycle/day, 85% round-trip): daily arbitrage value under PERFECT FORESIGHT (upper bound) vs a NAIVE fixed-hours heuristic (charge night / discharge evening). The gap = the value forecasting/RL could add. Annualize vs ~e50-80k/yr/MW needed to justify a battery. Trend = is it being competed away? """ import datetime import json import math import os import time import urllib.request import numpy as np CACHE = "data/surfer/energy" ETA = 0.85 # round-trip efficiency HRS = 4 # 4h battery, 1 MW -> 4 MWh / cycle ed = math.sqrt(ETA) def get(u): for attempt in range(6): try: return json.load(urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "curl/8"}), timeout=60)) except urllib.error.HTTPError as e: if e.code == 429: time.sleep(8 * (attempt + 1)); continue raise raise RuntimeError("rate-limited after retries") def fetch(): os.makedirs(CACHE, exist_ok=True) ts, px = [], [] for yr in range(2019, 2026): f = f"{CACHE}/de_{yr}.json" if os.path.exists(f): d = json.load(open(f)) else: d = get(f"https://api.energy-charts.info/price?bzn=DE-LU&start={yr}-01-01&end={yr}-12-31") json.dump({"unix_seconds": d.get("unix_seconds", []), "price": d.get("price", [])}, open(f, "w")) time.sleep(5) # space requests (avoid 429) ts += d["unix_seconds"]; px += d["price"] return np.array(ts), np.array(px, dtype=float) def main(): ts, px = fetch() ok = np.isfinite(px) ts, px = ts[ok], px[ok] day = np.array([datetime.datetime.utcfromtimestamp(int(t)).date().toordinal() for t in ts]) udays = np.unique(day) rows, dord = [], [] for d in udays: h = px[day == d] if len(h) == 24: # drop DST-transition days (23/25h) rows.append(h); dord.append(d) P = np.array(rows); D = np.array(dord) # [days, 24] yr = np.array([datetime.date.fromordinal(int(x)).year for x in D]) print(f"DE day-ahead: {len(P)} clean days ({datetime.date.fromordinal(int(D[0]))}..{datetime.date.fromordinal(int(D[-1]))})") # perfect-foresight 4h arbitrage per day (1 MW): discharge top-4 hrs, charge bottom-4 hrs srt = np.sort(P, axis=1) fore = srt[:, -HRS:].sum(1) * ed - srt[:, :HRS].sum(1) / ed # EUR/day per MW # naive fixed-hours heuristic: charge 02-05h, discharge 18-21h (typical shape) naive = P[:, 18:22].sum(1) * ed - P[:, 2:6].sum(1) / ed spread = P.max(1) - P.min(1) def stats(name, v): ann = np.mean(v) * 365 print(f" {name:>16}: e{np.mean(v):6.1f}/day/MW -> e{ann/1000:6.1f}k/yr/MW (median e{np.median(v):.1f}/day)") print(f"\n===== BATTERY ARBITRAGE ({HRS}h/1MW, {int(ETA*100)}% round-trip) =====") print(f"daily price spread: mean e{spread.mean():.1f}/MWh median e{np.median(spread):.1f} (negative-price days: {100*np.mean(P.min(1)<0):.0f}%)") stats("perfect-foresight", fore) stats("naive fixed-hours", naive) print(f" forecasting/RL gap (foresight-naive): e{(fore.mean()-naive.mean())*365/1000:.1f}k/yr/MW " f"= {100*(fore.mean()-naive.mean())/fore.mean():.0f}% of the value") print(f"\n viability: a 4h/1MW battery ~e400-600k capex; needs ~e50-80k/yr/MW arbitrage over 10y.") print(f" per-year perfect-foresight arb value (e k/yr/MW):") for y in range(2019, 2026): m = yr == y if m.sum() > 100: print(f" {y}: e{np.mean(fore[m])*365/1000:5.1f}k (naive e{np.mean(naive[m])*365/1000:.1f}k)") print("\nVERDICT: foresight value >> e50-80k threshold = battery arb economically real; big foresight-naive gap") print("= forecasting/RL adds real value (the engine's role); persistent across years = not yet competed away.") if __name__ == "__main__": main()