Tutorial Fallback: ES

Encontrar oportunidades de arbitraje en mercados eléctricos

Analiza precios de electricidad para identificar ventanas rentables de carga/descarga para sistemas de almacenamiento con baterías.

Una batería de 1 MWh puede capturar €50-100 al día de las oscilaciones de precios de electricidad—pero solo si sabes exactamente cuándo cargar y descargar. Este mapa de calor revela cada ventana rentable en un solo día: carga durante las horas oscuras, descarga en las zonas brillantes.

Matriz de oportunidades de arbitraje mostrando el potencial de beneficio para cada combinación de hora de carga/descarga

En este artículo, aprenderás a identificar sistemáticamente todas las ventanas de trading rentables y calcular retornos realistas después de considerar las pérdidas por eficiencia, límites de potencia y costes operativos.

Preguntas

  1. ¿Cómo cargar y visualizar datos de precios de electricidad?
  2. ¿Cuál es el beneficio teórico máximo en un día dado?
  3. ¿Qué combinaciones de períodos de carga/descarga ofrecen los mejores retornos?
  4. ¿Cómo clasificar oportunidades cuando hay cientos de opciones?
  5. ¿Qué restricciones prácticas afectan al trading real?

Implementación

Cargar datos de precios

Usaremos precios de electricidad de OMIE (el operador del mercado eléctrico español) con resolución de 15 minutos. Ver Obtener precios de electricidad de OMIE para detalles sobre cómo descargar estos datos.

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

Cargados 96 períodos (intervalos de 15 minutos) para 2026-01-10.

df_prices
Precios de electricidad a resolución de 15 minutos
Datos de precios

Visualizar la volatilidad de precios

Entender el patrón de precios revela cuándo los precios son más bajos (cargar) y más altos (descargar):

Volatilidad de precios de electricidad a lo largo del día

Rango de precios: 26.30 a 129.52 €/MWh (spread: 103.22 €/MWh).

El beneficio teórico máximo por MWh es el spread entre el período más bajo y el más alto: 103.22 €.

Construir la matriz de arbitraje

Un análisis simple de mín/máx pierde muchas oportunidades. ¿Y si el segundo período más bajo seguido del segundo más alto aún ofrece buenos retornos? Para encontrar todas las oportunidades, construiremos una matriz mostrando el potencial de beneficio para cada posible combinación de carga/descarga:

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]

Encontradas 2821 combinaciones rentables de 4560 pares posibles.

Mapa de calor mostrando el potencial de beneficio para todas las combinaciones de carga/descarga

El triángulo superior muestra todas las operaciones válidas (debes descargar después de cargar). Las celdas verdes son rentables, las rojas perderían dinero. La intensidad indica la magnitud.

Clasificar oportunidades

Con 2821 combinaciones rentables, necesitamos una forma sistemática de clasificarlas. Extraigamos y ordenemos por 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 oportunidades:

df_opportunities.head(10).round(2)
Las 10 mejores combinaciones de carga/descarga por spread
Mejores oportunidades

Mejor oportunidad: Cargar a las 12:45 (26.30 €/MWh), descargar a las 19:15 (129.52 €/MWh) para un spread de 103.22 €/MWh.

Simulación de ciclo único

Las operaciones individuales período a período son útiles para el análisis, pero las baterías necesitan períodos continuos de carga/descarga para operación práctica. Simulemos una estrategia realista: encontrar la ventana de 4 horas más barata para cargar y la ventana de 4 horas más cara para descargar.

def simulate_single_cycle(prices, charge_periods=16, discharge_periods=16):
    """Encontrar ventanas óptimas de carga/descarga (períodos son intervalos de 15 min)."""
    n = len(prices)

    # Encontrar ventana de carga más barata
    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

    # Encontrar ventana de descarga más cara (después de cargar)
    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 períodos = 4 horas a resolución de 15 min
cycle = simulate_single_cycle(prices, charge_periods=16, discharge_periods=16)

Ciclo único óptimo:

  • Carga: 11:30-15:30 a 34.88 €/MWh
  • Descarga: 17:45-21:45 a 114.87 €/MWh
  • Spread: 79.99 €/MWh
  • Beneficio teórico (1 MWh): 79.99 €
    Operación de carga/descarga de batería y beneficio acumulado durante 24 horas

Añadir restricciones realistas

El beneficio teórico asume una batería perfecta. Las baterías reales enfrentan limitaciones físicas que reducen la rentabilidad. Añadamos cada restricción incrementalmente.

Restricciones de SOC

El beneficio teórico asume que podemos usar el 100% de la capacidad de la batería. En la práctica, las baterías de ion-litio se degradan más rápido cuando se cargan al 100% o descargan al 0%. La mayoría de operadores limitan el estado de carga (SOC) al 10%-90%:

def simulate_with_soc(prices, capacity=1000, soc_min=0.10, soc_max=0.90,
                      charge_periods=16, discharge_periods=16):
    """Añadir restricciones de SOC para limitar capacidad utilizable."""
    n = len(prices)
    usable_capacity = capacity * (soc_max - soc_min)

    # Encontrar ventanas óptimas (mismo algoritmo)
    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

    # Calcular beneficio solo con capacidad utilizable
    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)
Impacto de los límites de SOC en el beneficio
Resultado con restricciones de SOC

La capacidad utilizable baja a 800 kWh (80% de la nominal), reduciendo el beneficio de 79.99 € a 63.99 €.

Límites de potencia

Las baterías tienen tasas máximas de carga/descarga. Una batería de 1 MWh podría cargar solo a 150 kW, limitando cuánta energía podemos mover en nuestra ventana de 4 horas:

def simulate_with_power(prices, capacity=1000, soc_min=0.10, soc_max=0.90,
                        max_power=150, charge_periods=16, discharge_periods=16):
    """Añadir límites de potencia sobre las restricciones de SOC."""
    n = len(prices)
    usable_capacity = capacity * (soc_max - soc_min)
    hours = charge_periods * 0.25  # períodos de 15-min a horas

    # El límite de potencia limita la transferencia de energía
    energy_limited = min(max_power * hours, usable_capacity)

    # Encontrar ventanas óptimas
    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)
Impacto de los límites de potencia en el beneficio
Resultado con límites de potencia

A 150 kW × 4h = 600 kWh, no podemos llenar los 800 kWh de capacidad utilizable. El beneficio baja a 47.99 €.

Pérdidas por eficiencia

Las baterías reales pierden energía durante los ciclos de carga/descarga. Una eficiencia de ida y vuelta del 90% significa que el 10% de la energía se pierde como calor:

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):
    """Añadir pérdidas por eficiencia - el modelo restringido completo."""
    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  # Aplicar pérdidas

    # Encontrar ventanas óptimas
    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)
Simulación completa con todas las restricciones
Resultado final con restricciones

Descargamos solo 540 kWh de los 600 kWh cargados. Beneficio final: 41.10 € — una reducción del 49% respecto al teórico.

Comparando escenarios

Visualicemos cómo cada restricción reduce nuestro beneficio teórico:

Gráfico de barras mostrando la reducción de beneficio desde operación teórica a restringida

Las restricciones reducen el beneficio diario un 48.6%, pero la operación sigue siendo rentable.

Análisis financiero

¿Puede una inversión en batería amortizarse solo con arbitraje? Calculemos el período de retorno:

Métricas de inversión y retorno
Análisis financiero
Flujos de caja anuales y acumulados mostrando la línea temporal de recuperación de inversión

Con un retorno de 51.1 años, el arbitraje por sí solo no puede justificar la inversión durante la vida útil de la batería. Después de 15 años (considerando un 2% de degradación anual), los retornos acumulados son €-285,438.

Conclusiones

El arbitraje de baterías en mercados eléctricos volátiles ofrece oportunidades de beneficio reales, pero las restricciones realistas impactan significativamente los retornos:

  • Spread teórico: 79.99 €/MWh
  • Beneficio diario con restricciones: 41.10 € (después de eficiencia y límites de potencia)
  • Beneficio neto anual: €7,051 (con 95% disponibilidad, 2% O&M)
  • Período de retorno: 51.1 años

Conclusiones clave:

  1. Las pérdidas por eficiencia (10%) y límites de potencia reducen los beneficios teóricos en ~49%
  2. El arbitraje por sí solo puede no justificar la inversión en baterías a precios actuales
  3. Los proyectos BESS exitosos típicamente combinan múltiples flujos de ingresos: regulación de frecuencia, mercados de capacidad, recorte de picos

Para mejores retornos, considera mercados con mayor volatilidad o combina arbitraje con otros servicios.

Subscribe to our newsletter

Get weekly insights on data, automation, and AI.

© 2026 Datons. All rights reserved.