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.

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
- ¿Cómo cargar y visualizar datos de precios de electricidad?
- ¿Cuál es el beneficio teórico máximo en un día dado?
- ¿Qué combinaciones de períodos de carga/descarga ofrecen los mejores retornos?
- ¿Cómo clasificar oportunidades cuando hay cientos de opciones?
- ¿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
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):

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.

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)
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 €

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)
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)
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)
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:

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:

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:
- Las pérdidas por eficiencia (10%) y límites de potencia reducen los beneficios teóricos en ~49%
- El arbitraje por sí solo puede no justificar la inversión en baterías a precios actuales
- 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.