Key Takeaways
- Real-time flexibility added 32–55% on top of day-ahead revenue depending on the month
- A linear programming optimizer finds the globally optimal schedule - no heuristic gets close
- Revenue is concentrated in a handful of high-spread days each month
- Perfect foresight results are a ceiling, not a forecast - but they're the right place to start
The question
Battery storage assets in ERCOT earn revenue by moving energy across time - charging when power is cheap, discharging when it's expensive. Simple in theory. In practice, a battery has hard physical constraints: limited power, limited energy, and the inability to charge and discharge simultaneously. These constraints make dispatch a genuine optimization problem, not a trading intuition.
This piece walks through how to model that problem using linear programming across ERCOT's two sequential markets - the Day-Ahead Market and the Real-Time Market. The central questions are:
- Given perfect price foresight, what is the theoretical maximum revenue a battery can earn?
- How much additional value does real-time flexibility add on top of a day-ahead-only strategy?
The answers matter because they set the ceiling for any dispatch algorithm. Before building a forecast model, you need to know what perfect dispatch looks like.
A quick primer on ERCOT's market structure
ERCOT - the Electric Reliability Council of Texas - is the wholesale electricity market covering most of Texas. Unlike PJM or CAISO, ERCOT operates as an energy-only market with no traditional capacity market, which makes ancillary services and energy arbitrage the primary revenue streams for battery operators.
Every delivery hour in ERCOT clears twice across two separate markets:
The Day-Ahead Market (DAM) clears the evening before physical delivery. Generators and load entities submit hourly bids, the market clears, and awarded positions settle financially at DAM prices. For a battery, this is where you commit your hourly charge/discharge schedule.
The Real-Time Market (RTM) settles every 15 minutes against actual grid conditions. If your physical output deviates from your DAM schedule, that deviation settles at real-time prices. RTM prices are more volatile than DAM prices - they respond to actual wind output, unexpected load, and transmission events in real time.
This two-settlement structure is what makes battery dispatch interesting. The DAM gives you price certainty and the RTM gives you flexibility on top of it. It's the optimizer's job to use both and maximise revenue.
The method
This model runs as a two-stage linear program - one stage per market.
Stage 1 optimizes hourly DAM positions using day-ahead prices only. The output is an awarded schedule: which hours the battery charges, which hours it discharges, and how much.
Stage 2 takes that DAM schedule as fixed and optimizes actual 15-minute dispatch against RTM prices. Any deviation from the DAM position settles at RTM prices. The output is the incremental PnL earned from real-time flexibility.
Total daily revenue is the sum of both stages. DA revenue comes from the committed hourly schedule; RT PnL comes from deviations - times when actual real-time prices made it worthwhile to do something different from the DA plan.
The end result tells you not just how much the battery earned, but where it earned it. Days where RT contributes heavily signal high DA-to-RT price divergence - typically driven by forecast errors or unexpected load events.
Assumptions
Before reading any further, understand what this model is and isn't.
Perfect foresight. Both stages assume prices are fully known in advance. In real operation, DAM bids go in without knowing RTM prices, and RTM dispatch relies on forecasts. This model gives the theoretical upper bound - what a perfect algorithm would have earned. Real revenues are lower.
Energy arbitrage only. This model does not include ancillary services - regulation, spinning reserve, or ECRS. In practice, co-optimizing across energy and AS markets materially changes both revenue and optimal dispatch strategy. That's a natural extension, not included here.
No degradation cost. Battery cells degrade with use. A cycle limit is used as a proxy, but a production model would penalize cycling directly in the objective function.
One cycle per day. The optimizer is constrained to one full charge/discharge cycle per day - a conservative operating assumption that reflects typical degradation-conscious practice.
These assumptions are deliberate. They keep the model clean and the results interpretable. The point is to understand the structure of the opportunity, not to replicate a live trading system.
1. The setup
The battery is configured as a 1 MW / 2 MWh asset - a 2-hour duration system. Small, but all results scale linearly with size, so the patterns hold for any capacity.
battery = {
'P_max': 1, # MW - maximum charge or discharge rate
'E_max': 2, # MWh - usable energy capacity
'eta': 0.92, # one-way efficiency (~85% round-trip)
'max_cycles': 1, # maximum full cycles per day
}
Data is hourly DAM settlement point prices and 15-minute RTM settlement point prices for a single ERCOT hub across Q1 2025 - January through March, roughly 90 days. DST-related duplicate hours are dropped before running the model. Any day without a complete 24-hour DAM series or full 96-interval RTM series is skipped.
2. The DAM optimizer
The DAM LP finds the globally optimal hourly schedule for a single day. Three sets of decision variables are defined for each of the 24 hours: charge power, discharge power, and state of charge.
c = {h: pl.LpVariable(f'c{h}', 0, P) for h in hours}
d = {h: pl.LpVariable(f'd{h}', 0, P) for h in hours}
soc = {h: pl.LpVariable(f'soc{h}', 0, E) for h in hours}
Each variable is bounded: charge and discharge are capped at the power rating; state of charge is capped at the energy capacity. The optimizer can freely choose any value within those bounds.
The objective is to maximise net revenue - discharge revenue minus charge cost across all 24 hours:
prob += pl.lpSum((d[h] - c[h]) * da_prices[h] for h in hours)
The SOC constraint is what separates this from a simple spread trade. Each hour's ending state of charge must physically follow from the previous hour, accounting for efficiency losses in both directions:
prob += soc[0] == c[0] * eta - d[0] / eta
for h in range(1, 24):
prob += soc[h] == soc[h-1] + c[h] * eta - d[h] / eta
This is the constraint that prevents the greedy answer. You can't discharge in hour 18 if you didn't charge enough in the hours before it. The optimizer sees all 24 hours simultaneously and finds the schedule that satisfies every constraint at once - not the locally optimal move each hour, but the globally best plan for the day.
3. The RTM optimizer
The RTM stage runs at 15-minute resolution - 96 intervals per day. The structure is identical to the DAM optimizer, but the DA schedule is now a fixed input, not a decision. The battery can deviate from that schedule; those deviations settle at RTM prices.
The incremental RT PnL is calculated interval by interval:
for i in intervals:
h = i // 4 # which hour does this interval belong to
da_net = da_sched['discharge'][h] - da_sched['charge'][h]
actual_net = act_d[i] - act_c[i]
rt_pnl += (actual_net - da_net) * rt_prices[i] * 0.25
i // 4 maps each 15-minute interval back to its parent hour. da_net is what was
committed in the DAM. actual_net is what actually happened. The deviation between the two,
multiplied by the RTM price and 0.25 (to convert MW to MWh for a 15-minute window), gives the RT PnL for
that interval.
The optimizer only deviates from the DA plan when RTM prices make it worthwhile. If RTM prices track DAM closely, deviations are small and RT PnL is low. If RTM spikes or crashes relative to DAM - a wind event, a transmission constraint clearing - the RT optimizer captures that spread.
4. Running it
The model runs day-by-day across Q1. For each date, it slices the day's prices, solves Stage 1, passes the DA schedule to Stage 2, and records DA revenue, RT PnL, and total revenue. The full quarter completes in a few seconds on a standard laptop.
5. Results
Across Q1 2025, total revenue for a 1 MW / 2 MWh battery was approximately $43,763 - with significant variation across the quarter.
| Month | DA Revenue | RT PnL | Total | RT Share |
|---|---|---|---|---|
| Jan | $8,678 | $10,619 | $19,297 | 55% |
| Feb | $8,017 | $7,205 | $15,223 | 47% |
| Mar | $6,243 | $3,000 | $9,243 | 33% |
The RT share decline from January to March is the headline finding. January's 55% RT uplift reflects significant DA-to-RT price divergence - conditions where the day-ahead market mispriced what actually happened in real time. By March, prices were more predictable, the DAM captured most of the opportunity, and RT flexibility added proportionally less.
For ERCOT hubs with significant wind exposure, this is a familiar pattern. Winter wind is volatile and difficult to forecast. When wind output swings unexpectedly, RTM prices spike or crash relative to DAM - and a battery with real-time flexibility earns on that divergence.
Daily revenue is not evenly distributed. The distribution is right-skewed - most days earn modestly, but a handful of high-spread days account for a disproportionate share of quarterly revenue. This concentration matters for forecasting. P50 and P90 estimates need to account for it, and any underwriting model that assumes smooth daily earnings will be wrong in ways that matter.
What one good day looks like
The highest-revenue day in Q1 - 15 January 2025 at $2,600 illustrates how the two stages interact in practice.
Single-day dispatch anatomy
The top panel shows DAM and RTM price profiles across the day. Early morning prices were at or near their daily lows - the DAM optimizer scheduled charging here. By late afternoon, prices spiked, and the battery discharged into the evening peak. The SOC profile confirms the battery respected its energy limits throughout.
The RT panel shows where actual dispatch diverged from the DA schedule. At several 15-minute intervals, RTM prices spiked above DAM - the optimizer responded by discharging more than the DA position in those intervals, capturing the extra spread. In a couple of intervals, RTM prices went briefly negative and the battery absorbed additional energy.
The key insight: the DA schedule is the backbone, RTM is where precision earns its keep. The DA optimizer sets the broad strokes; the RT optimizer fills in the texture.
Across all 90 days, DA price spread - the gap between the highest and lowest hourly DA prices - is the single strongest predictor of total daily revenue. Wide-spread days are almost always high-revenue days. The relationship is linear and strong.
RT uplift adds value on top, particularly on days where the hourly spread understates intraday volatility at the 15-minute level. Those are the green dots that sit above the trendline - days where something unexpected happened in real time that the DA market didn't price in.
What this doesn't model
Two gaps are worth naming directly.
Ancillary services. An ERCOT battery co-optimizing across regulation up/down, spinning reserve, and energy simultaneously earns more than energy arbitrage alone - and the optimal dispatch strategy changes materially. This is the natural next step, and the subject of a follow-up piece.
Forecast quality. The gap between perfect foresight and real-world price forecasts is where the actual analytical challenge sits. A dispatch algorithm that degrades gracefully on imperfect forecasts is worth far more than one that requires perfect information. Quantifying that gap - and understanding how much of the theoretical ceiling is actually achievable - is the more interesting question.