feat(energy): repurpose the engine — battery arbitrage is real, growing, forecasting-shaped

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) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-06 22:58:54 +02:00
parent 6c113b0df2
commit 7457ef679c

View File

@@ -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()