Tutorial

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.

Arbitrage opportunity matrix showing profit potential for every charge/discharge hour combination

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

  1. How do you load and visualize electricity price data?
  2. What’s the maximum theoretical profit in a given day?
  3. Which charge/discharge period combinations offer the best returns?
  4. How do you rank opportunities when there are hundreds of options?
  5. 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
Electricity prices at 15-minute resolution
Price data

Visualize price volatility

Understanding the price pattern reveals when prices are lowest (charge) and highest (discharge):

Electricity price volatility throughout the day

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.

Heatmap showing profit potential for all charge/discharge combinations

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 10 charge/discharge combinations by spread
Top opportunities

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 €
    Battery charge/discharge operation and cumulative profit over 24 hours

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)
Impact of SOC limits on profit
SOC Constraints Result

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)
Impact of power limits on profit
Power Limits Result

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)
Complete simulation with all constraints
Final Constrained Result

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:

Bar chart showing profit reduction from theoretical to constrained operation

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:

Investment and payback metrics
Financial Analysis
Annual and cumulative cash flows showing investment recovery timeline

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:

  1. Efficiency losses (10%) and power limits reduce theoretical profits by ~49%
  2. Arbitrage alone may not justify battery investment at current prices
  3. 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.

Subscribe to our newsletter

Get weekly insights on data, automation, and AI.

© 2026 Datons. All rights reserved.