Tutorial

Multi-Day Battery Arbitrage Analysis

Simulate a full year of constrained battery operation to understand profit variability and realistic annual expectations.

Daily arbitrage profits swing from €6 to €84—a 14x difference. This chart shows why single-day analysis misleads: you need a full year of simulation to understand what battery storage really earns.

Daily profit distribution showing high variability across 365 days

The conclusion? At current Spanish market prices, pure arbitrage barely covers operating costs. Annual net profit of ~€7,000 on a €360,000 investment means arbitrage alone doesn’t justify the battery. But this analysis sets the foundation for understanding where battery storage does work.

Questions

  1. How do constrained daily profits vary across a month?
  2. What’s the distribution of daily profits (best days vs worst days)?
  3. Are there weekly patterns in profitability?
  4. What’s a realistic annual profit projection based on simulated data?
  5. How does this compare to theoretical (unconstrained) analysis?

Implementation

Load price data

We’ll analyze 30 days of OMIE electricity prices at 15-minute resolution. See Fetching Electricity Prices from OMIE for the data source.

import pandas as pd
import numpy as np

df_prices = pd.read_csv('data/prices.csv', index_col='datetime', parse_dates=True)

Loaded 35040 periods across 365 days (2025-01-01 to 2025-12-31).

Define constrained simulation

We reuse the constrained model from the single-day article. The function finds optimal charge/discharge windows and applies:

  • SOC limits: 10%-90% (80% usable capacity)
  • Power limit: 150 kW maximum charge/discharge rate
  • Efficiency: 90% round-trip (10% energy loss)
def simulate_day_constrained(prices, capacity=1000, soc_min=0.10, soc_max=0.90,
                              max_power=150, efficiency=0.90,
                              charge_periods=16, discharge_periods=16):
    """Simulate one day with all constraints applied."""
    n = len(prices)
    if n < charge_periods + discharge_periods:
        return None

    usable_capacity = capacity * (soc_max - soc_min)
    hours = charge_periods * 0.25

    # Energy limited by power constraint
    energy_charged = min(max_power * hours, usable_capacity)
    energy_discharged = energy_charged * efficiency

    # Find optimal charge window
    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

    # Find optimal discharge window (after charging)
    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 profits
    charge_cost = energy_charged * min_avg / 1000
    discharge_revenue = energy_discharged * max_avg / 1000

    theoretical_profit = capacity * (max_avg - min_avg) / 1000
    constrained_profit = discharge_revenue - charge_cost

    return {
        'charge_price': min_avg,
        'discharge_price': max_avg,
        'spread': max_avg - min_avg,
        'theoretical_profit': theoretical_profit,
        'constrained_profit': constrained_profit,
        'efficiency_ratio': constrained_profit / theoretical_profit if theoretical_profit > 0 else 0
    }

Run 30-day simulation

daily_results = []
for date, day_data in df_prices.groupby(df_prices.index.date):
    prices = day_data['price_spain'].values
    result = simulate_day_constrained(prices)
    if result:
        result['date'] = pd.Timestamp(date)
        daily_results.append(result)

df_daily = pd.DataFrame(daily_results).set_index('date')
30 days of simulated profits
Daily Simulation Results

30-day totals:

  • Theoretical profit: €29,084
  • Constrained profit: €15,131 (51% of theoretical)

Profit variability

How much do daily constrained profits vary?

Bar chart and histogram of daily constrained profits
Distribution of daily constrained profits
Profit Statistics

The standard deviation of €17.29 shows significant day-to-day variability. Some days yield over €84, while the worst day produced only €6.

Weekly patterns

Do constrained profits follow a weekly pattern?

Bar chart showing average constrained profit by day of week

Weekday vs Weekend:

  • Weekdays: €41.46/day average
  • Weekends: €41.45/day average

Annual projection

Using 30 days of simulated data, we project annual returns:

Bar chart showing conservative, expected, and optimistic annual profits
Projected annual profits at 95% availability
Annual Projection

Financial analysis

Can arbitrage alone justify a battery investment?

Investment costs and payback calculation
Financial Analysis
Bar chart showing annual cash flows and cumulative returns

After 15 years (with 2% annual degradation), cumulative returns are €-287,653.

Conclusions

Multi-day simulation reveals the reality of battery arbitrage:

  • Average daily profit: €41.45 (constrained)
  • Variability: €6 to €84 per day
  • Annual projection (median): €14,078 gross
  • Annual net (after O&M): €6,878

Key insight: With median daily profits of €41, annual gross revenue (€14,078) barely covers O&M costs (€7,200). Arbitrage alone doesn’t justify battery investment at current Spanish market prices.

Successful BESS projects stack multiple revenue streams. In Spain, PV hybridization emerges as the most viable near-term path—combining solar self-consumption with storage fundamentally changes the economics. We’ll explore this in the next article.

Subscribe to our newsletter

Get weekly insights on data, automation, and AI.

© 2026 Datons. All rights reserved.