Finding Arbitrage Opportunities in Electricity Markets
Analyze electricity prices to identify profitable charge/discharge windows for battery storage systems.
A 1 MWh battery can capture €50-100 per day from electricity price swings—but only if you know exactly when to charge and discharge. This heatmap reveals every profitable window in a single day: charge during the dark hours, discharge in the bright zones.

In this article, you’ll learn to systematically identify all profitable trading windows and calculate realistic returns after accounting for efficiency losses, power limits, and operating costs.
Questions
- How do you load and visualize electricity price data?
- What’s the maximum theoretical profit in a given day?
- Which charge/discharge period combinations offer the best returns?
- How do you rank opportunities when there are hundreds of options?
- What practical constraints affect real-world trading?
Implementation
Load price data
We’ll use electricity prices from OMIE (the Spanish electricity market operator) at 15-minute resolution. See Fetching Electricity Prices from OMIE for details on how to download this data.
import pandas as pd
df_prices = pd.read_csv('data/prices.csv', index_col='datetime', parse_dates=True) Loaded 96 periods (15-minute intervals) for 2026-01-10.
df_prices
Visualize price volatility
Understanding the price pattern reveals when prices are lowest (charge) and highest (discharge):

Price range: 26.30 to 129.52 €/MWh (spread: 103.22 €/MWh).
The maximum theoretical profit per MWh is the spread between the lowest and highest period: 103.22 €.
Build the arbitrage matrix
A simple min/max analysis misses many opportunities. What if the second-lowest period followed by the second-highest still offers good returns? To find all opportunities, we’ll build a matrix showing the profit potential for every possible charge/discharge combination:
import numpy as np
profit_matrix = np.full((n_periods, n_periods), np.nan)
for charge_period in range(n_periods):
for discharge_period in range(charge_period + 1, n_periods):
profit_matrix[charge_period, discharge_period] = prices[discharge_period] - prices[charge_period] Found 2821 profitable combinations out of 4560 possible pairs.

The upper triangle shows all valid trades (you must discharge after charging). Green cells are profitable, red cells would lose money. The intensity indicates magnitude.
Rank opportunities
With 2821 profitable combinations, we need a systematic way to rank them. Let’s extract and sort by spread:
opportunities = []
for charge_period in range(n_periods):
for discharge_period in range(charge_period + 1, n_periods):
spread = profit_matrix[charge_period, discharge_period]
if spread > 0:
opportunities.append({
'charge_period': charge_period + 1,
'discharge_period': discharge_period + 1,
'buy_price': prices[charge_period],
'sell_price': prices[discharge_period],
'spread': spread,
'duration_hours': (discharge_period - charge_period) * 0.25
})
df_opportunities = pd.DataFrame(opportunities)
df_opportunities = df_opportunities.sort_values('spread', ascending=False).reset_index(drop=True) Top 10 opportunities:
df_opportunities.head(10).round(2)
Best opportunity: Charge at 12:45 (26.30 €/MWh), discharge at 19:15 (129.52 €/MWh) for a spread of 103.22 €/MWh.
Single-cycle simulation
Individual period-to-period trades are useful for analysis, but batteries need continuous charging/discharging periods for practical operation. Let’s simulate a realistic strategy: find the cheapest 4-hour window to charge and the most expensive 4-hour window to discharge.
def simulate_single_cycle(prices, charge_periods=16, discharge_periods=16):
"""Find optimal charge/discharge windows (periods are 15-min intervals)."""
n = len(prices)
# Find cheapest charging window
min_avg = float('inf')
best_charge_start = 0
for start in range(n - charge_periods + 1):
avg = prices[start:start+charge_periods].mean()
if avg < min_avg:
min_avg = avg
best_charge_start = start
charge_end = best_charge_start + charge_periods
# Find most expensive discharge window (after charging)
max_avg = -float('inf')
best_discharge_start = charge_end
for start in range(charge_end, n - discharge_periods + 1):
avg = prices[start:start+discharge_periods].mean()
if avg > max_avg:
max_avg = avg
best_discharge_start = start
return {
'charge_start': best_charge_start,
'charge_end': charge_end,
'discharge_start': best_discharge_start,
'discharge_end': best_discharge_start + discharge_periods,
'avg_charge_price': min_avg,
'avg_discharge_price': max_avg,
'spread': max_avg - min_avg
}
# 16 periods = 4 hours at 15-min resolution
cycle = simulate_single_cycle(prices, charge_periods=16, discharge_periods=16) Optimal single-cycle:
- Charge: 11:30-15:30 at 34.88 €/MWh
- Discharge: 17:45-21:45 at 114.87 €/MWh
- Spread: 79.99 €/MWh
- Theoretical profit (1 MWh): 79.99 €

Adding realistic constraints
The theoretical profit assumes a perfect battery. Real batteries face physical limitations that reduce profitability. Let’s add each constraint incrementally.
SOC constraints
The theoretical profit assumes we can use 100% of the battery capacity. In practice, lithium-ion batteries degrade faster when charged to 100% or discharged to 0%. Most operators limit the state of charge (SOC) to 10%-90%:
def simulate_with_soc(prices, capacity=1000, soc_min=0.10, soc_max=0.90,
charge_periods=16, discharge_periods=16):
"""Add SOC constraints to limit usable capacity."""
n = len(prices)
usable_capacity = capacity * (soc_max - soc_min)
# Find optimal windows (same algorithm)
min_avg, best_charge_start = float('inf'), 0
for start in range(n - charge_periods + 1):
avg = prices[start:start+charge_periods].mean()
if avg < min_avg:
min_avg, best_charge_start = avg, start
charge_end = best_charge_start + charge_periods
max_avg, best_discharge_start = -float('inf'), charge_end
for start in range(charge_end, n - discharge_periods + 1):
avg = prices[start:start+discharge_periods].mean()
if avg > max_avg:
max_avg, best_discharge_start = avg, start
# Calculate profit with usable capacity only
spread = max_avg - min_avg
profit = usable_capacity * spread / 1000
return {'profit': profit, 'usable_capacity': usable_capacity,
'avg_charge_price': min_avg, 'avg_discharge_price': max_avg}
result_soc = simulate_with_soc(prices)
Usable capacity drops to 800 kWh (80% of rated), reducing profit from 79.99 € to 63.99 €.
Power limits
Batteries have maximum charge/discharge rates. A 1 MWh battery might only charge at 150 kW, limiting how much energy we can move in our 4-hour window:
def simulate_with_power(prices, capacity=1000, soc_min=0.10, soc_max=0.90,
max_power=150, charge_periods=16, discharge_periods=16):
"""Add power limits on top of SOC constraints."""
n = len(prices)
usable_capacity = capacity * (soc_max - soc_min)
hours = charge_periods * 0.25 # 15-min periods to hours
# Power limit caps energy transfer
energy_limited = min(max_power * hours, usable_capacity)
# Find optimal windows
min_avg, best_charge_start = float('inf'), 0
for start in range(n - charge_periods + 1):
avg = prices[start:start+charge_periods].mean()
if avg < min_avg:
min_avg, best_charge_start = avg, start
charge_end = best_charge_start + charge_periods
max_avg, best_discharge_start = -float('inf'), charge_end
for start in range(charge_end, n - discharge_periods + 1):
avg = prices[start:start+discharge_periods].mean()
if avg > max_avg:
max_avg, best_discharge_start = avg, start
spread = max_avg - min_avg
profit = energy_limited * spread / 1000
return {'profit': profit, 'energy_charged': energy_limited,
'avg_charge_price': min_avg, 'avg_discharge_price': max_avg}
result_power = simulate_with_power(prices)
At 150 kW × 4h = 600 kWh, we can’t fill the 800 kWh usable capacity. Profit drops to 47.99 €.
Efficiency losses
Real batteries lose energy during charge/discharge cycles. A 90% round-trip efficiency means 10% of energy is lost as heat:
def simulate_with_efficiency(prices, capacity=1000, soc_min=0.10, soc_max=0.90,
max_power=150, efficiency=0.90,
charge_periods=16, discharge_periods=16):
"""Add efficiency losses - the complete constrained model."""
n = len(prices)
usable_capacity = capacity * (soc_max - soc_min)
hours = charge_periods * 0.25
energy_charged = min(max_power * hours, usable_capacity)
energy_discharged = energy_charged * efficiency # Apply losses
# Find optimal windows
min_avg, best_charge_start = float('inf'), 0
for start in range(n - charge_periods + 1):
avg = prices[start:start+charge_periods].mean()
if avg < min_avg:
min_avg, best_charge_start = avg, start
charge_end = best_charge_start + charge_periods
max_avg, best_discharge_start = -float('inf'), charge_end
for start in range(charge_end, n - discharge_periods + 1):
avg = prices[start:start+discharge_periods].mean()
if avg > max_avg:
max_avg, best_discharge_start = avg, start
charge_cost = energy_charged * min_avg / 1000
discharge_revenue = energy_discharged * max_avg / 1000
return {'profit': discharge_revenue - charge_cost,
'energy_charged': energy_charged,
'energy_discharged': energy_discharged,
'charge_cost': charge_cost,
'discharge_revenue': discharge_revenue}
result_final = simulate_with_efficiency(prices)
We discharge only 540 kWh of the 600 kWh charged. Final profit: 41.10 € — a 49% reduction from theoretical.
Comparing scenarios
Let’s visualize how each constraint reduces our theoretical profit:

Constraints reduce daily profit by 48.6%, but the operation remains profitable.
Financial analysis
Can a battery investment pay for itself with arbitrage alone? Let’s calculate the payback period:

With a 51.1-year payback, arbitrage alone struggles to justify the investment within the battery’s lifetime. After 15 years (accounting for 2% annual degradation), cumulative returns are €-285,438.
Conclusions
Battery arbitrage in volatile electricity markets offers real profit opportunities, but realistic constraints significantly impact returns:
- Theoretical spread: 79.99 €/MWh
- Constrained daily profit: 41.10 € (after efficiency and power limits)
- Annual net profit: €7,051 (with 95% availability, 2% O&M)
- Payback period: 51.1 years
Key insights:
- Efficiency losses (10%) and power limits reduce theoretical profits by ~49%
- Arbitrage alone may not justify battery investment at current prices
- Successful BESS projects typically stack multiple revenue streams: frequency regulation, capacity markets, peak shaving
For better returns, consider markets with higher volatility or combine arbitrage with other services.