Continuous Intraday Demo: Rolling Intrinsic Policy
Introduction
This example demonstrates how to use RTC-Tools to optimise a BESS for continuous intraday trading using the rolling intrinsic policy. The optimisation makes trading decisions based on the current orderbook state with a receding horizon, allocating power across multiple price levels in the bid and ask orderbooks.
The rolling intrinsic policy is a widely-used approach for real-time battery trading that optimizes over a receding horizon window, making decisions based on current market conditions.
Problem Formulation
Mathematical Model
The continuous intraday trading problem extends the basic BESS optimization with orderbook-aware trading:
Objective Function:
- Where:
\(P_{discharge,i}^{new}(t)\) = New power sold at bid level \(i\) (intraday trades only)
\(P_{charge,i}^{new}(t)\) = New power bought at ask level \(i\) (intraday trades only)
\(\pi_{bid,i}(t)\) = Bid price at level \(i\) (descending order)
\(\pi_{ask,i}(t)\) = Ask price at level \(i\) (ascending order)
\(N\) = Number of orderbook levels (default: 10)
\(\lambda_{tx}\) = Transaction cost per MWh traded
\(\lambda_{cyc}\) = Cycling penalty factor (requires tuning based on market conditions)
Power Allocation Constraints:
- Where:
\(V_{bid,i}(t)\) = Available volume at bid level \(i\)
\(V_{ask,i}(t)\) = Available volume at ask level \(i\)
Committed Position Integration:
The model accounts for committed positions from day ahead and prior intraday trades:
- Where:
\(P_{committed}(t)\) = Net committed power from prior trades (positive=discharge, negative=charge)
\(P_{net}(t)\) = Total net power position after netting committed and new intraday trades
Splitting Constraint:
The net power is decomposed into non-negative charge and discharge components:
This ensures at most one component is non-zero, allowing proper netting of positions.
State of Charge Dynamics:
The SOC dynamics are governed by the total netted position components, not just the new trades.
Model Implementation
Architecture Overview
The continuous intraday model extends the scheduling demo architecture with orderbook-aware trading:
- Physical Asset Model (Modelica)
- The Modelica model (
BESSIntraday.mo) includes: State of charge dynamics with efficiency losses
Power allocation arrays across orderbook levels
Orderbook price and volume arrays (configurable \(N\) levels)
Power flow calculations
- The Modelica model (
- Value Stream Model (Python)
- The Python implementation (
bess_intraday.py) handles: Revenue calculations from orderbook trading
Transaction cost modeling
Objective function formulation
Volume constraints for each orderbook level
- The Python implementation (
Physical Asset Model (Modelica)
The BESS intraday model with orderbook arrays:
model BESSIntraday
// Parameters
parameter Real capacity = 100.0 "Battery capacity in MWh";
parameter Real efficiency = 0.9 "Round-trip efficiency";
parameter Real max_power = 50.0 "Maximum charge/discharge power in MW";
parameter Integer n_orderbook_entries = 10 "Number of orderbook entries per direction";
// Variables
output Real soc(start=50.0, min=0.0, max=capacity) "State of charge in MWh";
output Real charge_power(min=0.0, max=max_power) "Charging power in MW";
output Real discharge_power(min=0.0, max=max_power) "Discharging power in MW";
output Real net_power "Net power (positive = discharge, negative = charge) in MW";
// Binary variables for complementarity
Boolean is_charging "True if battery is charging";
Boolean is_discharging "True if battery is discharging";
// Input variables - committed net position from day ahead and prior intraday trades
input Real committed_net_power(fixed = true) "Committed net power from prior trades (MW, positive=discharge, negative=charge)";
// Input variables - orderbook bid and ask prices (time-varying)
input Real bid_prices[n_orderbook_entries](each fixed = true) "Bid prices ($/MWh), sorted descending";
input Real ask_prices[n_orderbook_entries](each fixed = true) "Ask prices ($/MWh), sorted ascending";
// Orderbook volumes
input Real bid_volumes[n_orderbook_entries](each fixed = true) "Bid volumes (MW)";
input Real ask_volumes[n_orderbook_entries](each fixed = true) "Ask volumes (MW)";
// Power allocation across orderbook levels (decision variables)
input Real discharge_power_bids[n_orderbook_entries](each fixed = false, each min=0.0) "Power sold at each bid level (MW)";
input Real charge_power_asks[n_orderbook_entries](each fixed = false, each min=0.0) "Power bought at each ask level (MW)";
equation
// State of charge dynamics
3600 * der(soc) = charge_power * sqrt(efficiency) - discharge_power / sqrt(efficiency);
// Net power calculation
net_power = committed_net_power + sum(discharge_power_bids) - sum(charge_power_asks);
// Decompose net power
net_power = discharge_power - charge_power;
end BESSIntraday;
- Key features:
Parametrized number of orderbook entries (
n_orderbook_entries)Bid/ask price and volume arrays
Power allocation decision variables (
discharge_power_bids,charge_power_asks)
Value Stream Model (Python)
Objective Function (Economic Model):
def path_objective(self, ensemble_member):
"""
Define optimization objective: maximize trading profit.
For the rolling intrinsic policy, we optimize the expected value
based on current orderbook state, with power allocated across
different price levels.
"""
# Revenue from selling to bids (discharging)
total_discharge = 0.0
discharge_revenue = 0.0
for i in range(self.n_entries):
bid_price = self.state(f'bid_prices[{i+1}]')
discharge_power_i = self.state(f'discharge_power_bids[{i+1}]')
total_discharge += discharge_power_i
discharge_revenue += bid_price * discharge_power_i
# Cost of buying from asks (charging)
total_charge = 0.0
charge_cost = 0.0
for i in range(self.n_entries):
ask_price = self.state(f'ask_prices[{i+1}]')
charge_power_i = self.state(f'charge_power_asks[{i+1}]')
total_charge += charge_power_i
charge_cost += ask_price * charge_power_i
# Transaction costs on total traded volume
transaction_cost = self.transaction_cost * (total_charge + total_discharge)
# Cycling penalty based on total power throughput
cycling_penalty = self.cycling_penalty_factor * (total_charge + total_discharge)
# Total objective (negative because we want to maximize profit)
profit = discharge_revenue - charge_cost - transaction_cost - cycling_penalty
return -profit
- The objective function maximizes trading profit by:
Calculating revenue from selling to each bid level
Calculating costs from buying from each ask level
Subtracting transaction costs on total traded volume
Subtracting cycling penalties to account for battery degradation
Path Constraints:
def path_constraints(self, ensemble_member):
"""Define path constraints (inequality constraints over time)."""
constraints = super().path_constraints(ensemble_member)
parameters = self.parameters(ensemble_member)
# Ensure only one mode can be active at a time (complementarity)
constraints.append((
self.state('is_charging') +
self.state('is_discharging'),
-np.inf,
1.0,
))
constraints.append((
self.state('charge_power') -
self.state('is_charging') * parameters["max_power"],
-np.inf,
0,
))
constraints.append((
self.state('discharge_power') -
self.state('is_discharging') * parameters["max_power"],
-np.inf,
0,
))
# Power allocated to each level cannot exceed available volume
for i in range(self.n_entries):
# Discharge limited by bid volume: discharge_power_bids[i] <= bid_volumes[i]
# Reformulated as: discharge_power_bids[i] - bid_volumes[i] <= 0
constraints.append((
self.state(f'discharge_power_bids[{i+1}]') - self.state(f'bid_volumes[{i+1}]'),
-np.inf,
0.0,
))
# Charge limited by ask volume: charge_power_asks[i] <= ask_volumes[i]
# Reformulated as: charge_power_asks[i] - ask_volumes[i] <= 0
constraints.append((
self.state(f'charge_power_asks[{i+1}]') - self.state(f'ask_volumes[{i+1}]'),
-np.inf,
0.0,
))
return constraints
- The path constraints enforce:
Complementarity between charging and discharging
Volume limits for each orderbook level
Input Data
Note
The orderbook data provided in this example is randomly generated for demonstration purposes and does not represent real market orderbook data. In a production setting, this data would be sourced from actual market feeds or trading platforms.
Note
The optimizer automatically adjusts to the time resolution of the input data. If you provide input data with 15-minute intervals, the optimizer will run at 15-minute frequency. Similarly, 5-minute input data (as in this example) results in 5-minute optimization intervals. This flexibility allows the same model to be used across different market time resolutions without code changes.
The example uses two CSV input files:
- timeseries_import.csv
- Contains orderbook data with bid/ask prices and volumes for each level:
committed_net_power- Net committed power from day ahead and prior intraday trades (MW, positive=discharge, negative=charge)bid_prices[1]tobid_prices[10]- Bid prices (descending)ask_prices[1]toask_prices[10]- Ask prices (ascending)bid_volumes[1]tobid_volumes[10]- Available bid volumesask_volumes[1]toask_volumes[10]- Available ask volumes
- initial_state.csv
Specifies the initial state of charge (e.g., resulting from previous day ahead/intraday trading).
Running the Example
Install Dependencies:
uv syncNavigate to the Example Directory:
cd continuous_intraday
Run the Optimization:
uv run python src/bess_intraday.py
This will: * Solve the rolling intrinsic optimisation problem * Export results to
output/timeseries_export.csvGenerate Plots and Summary:
uv run python src/plot_results.py
- This will:
Read the exported CSV results
Calculate trading metrics (revenue, costs, trades)
Generate visualization plots including orderbook allocation
Display summary statistics
Save plots to
output/bess_intraday_results.png
Alternative: Run Both Steps Together:
uv run python src/bess_intraday.py && uv run python src/plot_results.py
Results and Analysis
- The optimization produces detailed visualizations showing:
Battery state of charge profile
Charge/discharge power decisions
Orderbook bid-ask spread dynamics
Power allocation across different orderbook levels (stacked bars)
Cumulative trading revenue
Figure: BESS intraday trading results showing (top to bottom): State of Charge profile, Charge/Discharge power decisions, Orderbook mid price and bid-ask spread, Power allocation across orderbook levels, All orderbook price levels, All orderbook volume levels, and Cumulative trading revenue.
- The rolling intrinsic policy enables the battery to:
React to real-time orderbook conditions
Optimize across multiple price levels
Account for liquidity constraints at each level
Maximize trading profit while respecting volume limits