So far, your optimizer takes prices as given and produces a dispatch schedule. But in reality, you don't know tomorrow's prices when you bid. The day-ahead auction clears all bids simultaneously — your bid influences the price. This lesson teaches you how the day-ahead market actually works and how to translate your optimizer output into valid exchange bids.
The EPEX SPOT day-ahead auction (used across Central/Western Europe) operates like this:
You submit a price-quantity pair for each hour. Example: "I will buy 10 MW in hour 3 if price ≤ €25/MWh" or "I will sell 10 MW in hour 17 if price ≥ €60/MWh."
For a battery, you submit two bids per hour:
A block bid is an all-or-nothing bid spanning multiple consecutive hours. Example: "Buy 10 MW for hours 1–4 at average price ≤ €22/MWh — accept all hours or none." Useful for batteries because charging needs a sequence of hours, not just one.
Parent-child links: "Accept my discharge block (hours 17–19) only if my charge block (hours 2–5) was also accepted." This ensures the battery has energy before it tries to sell.
The simplest approach — and the one used by most BESS operators in production:
"""
day_ahead_bidding.py — Generate exchange bids from optimizer output
"""
import pyomo.environ as pyo
import numpy as np
import pandas as pd
# ... (model from Lesson 3, solved) ...
# Assume: model is solved, schedule DataFrame exists
# --- Bid generation ---
price_forecast = prices # In production: from your forecasting model
margin = 2.0 # €/MWh — bid margin (tolerance for forecast error)
bids = []
for t in range(T):
charge_mw = round(pyo.value(model.p_charge[t]), 2)
discharge_mw = round(pyo.value(model.p_discharge[t]), 2)
if charge_mw > 0.1: # Charging hour
# Buy bid: willing to pay up to forecast + margin
bids.append({
'hour': t,
'direction': 'BUY',
'quantity_mw': charge_mw,
'price_eur_mwh': round(price_forecast[t] + margin, 2),
'type': 'hourly'
})
elif discharge_mw > 0.1: # Discharging hour
# Sell bid: willing to sell at forecast - margin (minimum)
bids.append({
'hour': t,
'direction': 'SELL',
'quantity_mw': discharge_mw,
'price_eur_mwh': round(price_forecast[t] - margin, 2),
'type': 'hourly'
})
bid_df = pd.DataFrame(bids)
print(bid_df.to_string(index=False))
If the clearing price falls between your charge and discharge bid prices, some hours may not clear. Your schedule is then partially filled. This is a problem: maybe only the charge hours cleared but not the discharge hours. You're stuck with a full battery and no sell position.
Solutions:
Many battery operators skip the price-guessing entirely and submit price-taker bids:
# Price-taker: guarantee acceptance, take whatever price clears
price_taker_bids = []
for t in range(T):
charge_mw = round(pyo.value(model.p_charge[t]), 2)
discharge_mw = round(pyo.value(model.p_discharge[t]), 2)
if charge_mw > 0.1:
price_taker_bids.append({
'hour': t,
'direction': 'BUY',
'quantity_mw': charge_mw,
'price_eur_mwh': 3000.00, # Max allowed — always accepted
'type': 'hourly'
})
elif discharge_mw > 0.1:
price_taker_bids.append({
'hour': t,
'direction': 'SELL',
'quantity_mw': discharge_mw,
'price_eur_mwh': -500.00, # Min allowed — always accepted
'type': 'hourly'
})
┌─────────────────────────────────────────────┐
│ D-1, morning: receive price forecast │
│ ↓ │
│ Run optimizer → get schedule │
│ ↓ │
│ Generate bids (hourly or block) │
│ ↓ │
│ Submit to exchange before 12:00 CET │
│ ↓ │
│ ~12:42: receive clearing results │
│ ↓ │
│ Check accepted vs. rejected bids │
│ ↓ │
│ If partial fill → correct in intraday │
└─────────────────────────────────────────────┘
A battery submits a SELL bid at €65/MWh for hour 18. The clearing price is €72/MWh. What happens?
Why might a battery operator use linked block bids instead of independent hourly bids?
What is the main risk of submitting price-taker bids (buy at €3000, sell at -€500)?
EPEX SPOT — Day-Ahead Trading Products — official documentation on hourly bids, block bids, linked blocks, and clearing rules. Read the "Order Types" section.
Rechtliches: Impressum