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>
94 lines
4.1 KiB
Python
94 lines
4.1 KiB
Python
#!/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()
|