Intraday Continuous Trading

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

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.

Day-ahead vs. Intraday: key differences

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
Pay-as-bid matters: In the intraday market, your bid price IS your execution price. There's no uniform clearing price. This means your bidding strategy directly determines revenue — bid too aggressively and you leave money on the table, bid too passively and you don't get filled.

Why batteries love intraday

Three main use cases for battery storage in intraday:

  1. Schedule correction: Your day-ahead position was based on a forecast. If prices moved, trade out of losing hours and into better ones.
  2. Additional spread capture: Intraday volatility often exceeds day-ahead. Short-term price spikes (wind drops, plant outages) create arbitrage opportunities.
  3. Quarter-hourly trading: Batteries can react in 15-minute granularity (or faster). Quarter-hourly products capture value that hourly products miss — especially around sunrise/sunset ramps.

The rolling-horizon optimizer

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

Implementation: the delta approach

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

Order types in the continuous market

When submitting intraday orders, you choose:

Practical tip: For schedule corrections (you must trade), use aggressive limit orders close to the current best price. For opportunistic spread capture (nice-to-have), use patient limit orders and let the market come to you.

Trigger-based reoptimization

You don't want to reoptimize every second. Common triggers:

Check your understanding

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?

📖 Recommended reading

EPEX SPOT — Intraday Continuous Trading — official documentation on order types, gate closure times, and cross-border coupling. Focus on "Order Types" and "Products."

💬 Questions? The interplay between day-ahead positions and intraday reoptimization is where most of the money is made. Ask about specific scenarios — e.g., what happens when your entire day-ahead schedule was wrong?

Rechtliches: Impressum