Day-Ahead Bidding: From Schedule to Bids

← Learning Hub Lesson 4 · Battery Optimizer Course · Estimated time: 20 minutes

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.

How the day-ahead auction works

The EPEX SPOT day-ahead auction (used across Central/Western Europe) operates like this:

  1. Gate opens: Market participants submit bids for each hour of tomorrow (products: hourly, block).
  2. Gate closes: Typically 12:00 CET on day D-1.
  3. Clearing: The exchange aggregates all buy and sell bids into supply/demand curves per hour. The intersection determines the Market Clearing Price (MCP).
  4. Results: Published ~12:42 CET. If your bid price ≤ MCP (for sells) or ≥ MCP (for buys), you're accepted.
Price-taker assumption: For a single battery (say 10 MW), your bids don't measurably move the MCP. You can safely treat price as exogenous. This breaks down only for very large portfolios.

Bid types for batteries

Hourly bids (stepwise or linear)

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:

Block bids (linked hours)

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.

Linked block bids

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.

From optimizer to bids: the strategy

The simplest approach — and the one used by most BESS operators in production:

  1. Forecast prices — use a price forecast for tomorrow's 24 hours
  2. Run your optimizer from Lesson 3 using the forecasted prices
  3. Determine bid prices — for each hour, calculate your breakeven price:
  4. Submit bids — quantities from optimizer, prices from step 3
"""
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))
The margin trade-off: A wider margin increases acceptance probability but reduces revenue. A tighter margin risks non-acceptance if your forecast is slightly off. In practice, 1–3 €/MWh margins are common for price-taker batteries.

What if bids are partially accepted?

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:

Price-taker bidding (the simple approach)

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'
        })
Why this works: If your price forecast is good and your optimizer found the right hours, price-taker bids guarantee execution. You earn the actual clearing price, which should be close to your forecast. The risk is that prices diverge significantly from forecast — then you've committed to a bad trade.

The full bidding workflow

┌─────────────────────────────────────────────┐
│  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      │
└─────────────────────────────────────────────┘

Check your understanding

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)?

📖 Recommended reading

EPEX SPOT — Day-Ahead Trading Products — official documentation on hourly bids, block bids, linked blocks, and clearing rules. Read the "Order Types" section.

💬 Questions? Bidding strategy has many nuances — block bid sizing, curtailable blocks, paradoxically rejected bids. Ask if you want to go deeper on any of these.

Rechtliches: Impressum