The day-ahead auction (Lesson 4) commits your battery to a schedule 12+ hours before delivery. But reality shifts: wind forecasts update, a power plant trips, demand deviates. The intraday continuous market lets you adjust your position right up to delivery.
| Feature | Day-Ahead | Intraday Continuous |
|---|---|---|
| Mechanism | Auction (single clearing) | Continuous (order book, pay-as-bid) |
| Pricing | Uniform price | Pay-as-bid (you get your bid price) |
| Gate closure | 12:00 D-1 | 5 min before delivery (varies by TSO) |
| Products | Hourly, block | Hourly, half-hourly, quarter-hourly |
| Liquidity | Very high | Good for hourly, thinner for 15-min |
| Battery use case | Base schedule | Reoptimization, spread capture |
Three main use cases for battery storage in intraday:
For intraday, you don't optimize once — you reoptimize continuously as new information arrives. The pattern:
┌───────────────────────────────────────────────────┐
│ Every 15 minutes (or on price trigger): │
│ │
│ 1. Current SoC → read from BMS │
│ 2. Existing positions → from trading system │
│ 3. Updated price forecast → latest model output │
│ 4. Remaining hours → only future, not past │
│ │
│ Run optimizer on remaining horizon │
│ ↓ │
│ Compare new schedule vs. existing positions │
│ ↓ │
│ Delta = trades needed to move to new schedule │
│ ↓ │
│ Submit delta as intraday orders │
└───────────────────────────────────────────────────┘
"""
intraday_reoptimize.py — Rolling-horizon reoptimization
"""
import pyomo.environ as pyo
import numpy as np
import pandas as pd
from datetime import datetime
def reoptimize(current_soc, existing_positions, price_forecast,
remaining_hours, P_max, E_max, eta, deg_cost):
"""
Re-run the optimizer for the remaining delivery period.
Args:
current_soc: Current battery SoC in MWh (from BMS)
existing_positions: dict {hour: net_mw} — already committed trades
price_forecast: dict {hour: price} — latest forecast
remaining_hours: list of hours still tradeable
Returns:
DataFrame with delta trades to submit
"""
T_remaining = len(remaining_hours)
hours = remaining_hours
eta_sqrt = np.sqrt(eta)
model = pyo.ConcreteModel()
model.T = pyo.Set(initialize=range(T_remaining))
model.p_charge = pyo.Var(model.T, bounds=(0, P_max))
model.p_discharge = pyo.Var(model.T, bounds=(0, P_max))
model.soc = pyo.Var(model.T, bounds=(0.1 * E_max, 0.9 * E_max))
model.y = pyo.Var(model.T, within=pyo.Binary)
# Objective: maximize remaining revenue minus degradation
def obj_rule(m):
revenue = sum(
price_forecast[hours[i]] * (m.p_discharge[i] - m.p_charge[i])
for i in m.T
)
deg = sum(deg_cost * (m.p_charge[i] + m.p_discharge[i]) for i in m.T)
return revenue - deg
model.obj = pyo.Objective(rule=obj_rule, sense=pyo.maximize)
# SoC balance (starting from current_soc)
def soc_rule(m, i):
prev = current_soc if i == 0 else m.soc[i-1]
return m.soc[i] == prev + eta_sqrt * m.p_charge[i] - m.p_discharge[i] / eta_sqrt
model.soc_bal = pyo.Constraint(model.T, rule=soc_rule)
# No simultaneous charge/discharge
def nc(m, i): return m.p_charge[i] <= P_max * (1 - m.y[i])
def nd(m, i): return m.p_discharge[i] <= P_max * m.y[i]
model.nc = pyo.Constraint(model.T, rule=nc)
model.nd = pyo.Constraint(model.T, rule=nd)
# Solve
solver = pyo.SolverFactory('highs')
solver.solve(model, tee=False)
# Compute delta: new optimal schedule minus existing positions
deltas = []
for i, h in enumerate(hours):
new_net = pyo.value(model.p_discharge[i]) - pyo.value(model.p_charge[i])
existing_net = existing_positions.get(h, 0.0)
delta = new_net - existing_net
if abs(delta) > 0.1: # Only trade if delta is meaningful
deltas.append({
'hour': h,
'direction': 'SELL' if delta > 0 else 'BUY',
'quantity_mw': round(abs(delta), 2),
'limit_price': round(price_forecast[h] + (2 if delta < 0 else -2), 2),
})
return pd.DataFrame(deltas)
# --- Example usage ---
# It's 14:00, day-ahead positions are set, prices have moved
current_soc = 8.5 # MWh (read from battery management system)
existing_positions = {
14: 0, 15: 0, 16: -10, 17: -10, 18: 10, 19: 10, 20: 10, 21: 0, 22: 0, 23: 0
# negative = charging, positive = discharging
}
# Updated price forecast (prices spiked for hours 15-16 due to wind drop)
price_forecast = {
14: 55, 15: 78, 16: 82, 17: 88, 18: 95, 19: 75, 20: 58, 21: 42, 22: 35, 23: 30
}
remaining_hours = list(range(14, 24))
deltas = reoptimize(
current_soc=current_soc,
existing_positions=existing_positions,
price_forecast=price_forecast,
remaining_hours=remaining_hours,
P_max=10.0, E_max=20.0, eta=0.90, deg_cost=5.0
)
print("Intraday orders to submit:")
print(deltas.to_string(index=False))
When submitting intraday orders, you choose:
You don't want to reoptimize every second. Common triggers:
In the intraday continuous market, if you submit a sell order at €70/MWh and it executes, how much are you paid?
The "delta approach" compares the new optimal schedule to existing positions. Why not just cancel all existing positions and resubmit?
Why are quarter-hourly products particularly valuable for batteries?
EPEX SPOT — Intraday Continuous Trading — official documentation on order types, gate closure times, and cross-border coupling. Focus on "Order Types" and "Products."
Rechtliches: Impressum