From 7457ef679c71aef5b5864265cd0d1669c0a9f995 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sat, 6 Jun 2026 22:58:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(energy):=20repurpose=20the=20engine=20?= =?UTF-8?q?=E2=80=94=20battery=20arbitrage=20is=20real,=20growing,=20forec?= =?UTF-8?q?asting-shaped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First repurpose target with real legs. DE day-ahead battery arbitrage (free Energy-Charts, 2019-2025, 2464 days). 4h/1MW/85pct: perfect-foresight e93k/yr/MW (>> e50-80k viability), naive only e10k -> forecasting/RL gap = 89pct of value. Per-year GROWING (e28k 2019 -> e141k 2025) = not competed away (batteries slow to build = operational moat). Structurally opposite of trading: real economic value (not zero-sum), growing (not arbitraged), and 89pct of value is forecasting/RL = exactly the engine's job. Caveats: perfect-foresight is upper bound (realistic ~60-85pct capture -> ~e55-80k/yr/MW); needs a battery (capital) or sell the dispatch software to operators (engine-as-product) or aggregator. NEXT: forecaster + RL dispatch, measure realized-capture vs foresight. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/surfer/energy_battery_gate.py | 93 +++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 scripts/surfer/energy_battery_gate.py diff --git a/scripts/surfer/energy_battery_gate.py b/scripts/surfer/energy_battery_gate.py new file mode 100644 index 000000000..857e83099 --- /dev/null +++ b/scripts/surfer/energy_battery_gate.py @@ -0,0 +1,93 @@ +#!/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()