Trading Strategies
The engine provides three optimization strategies for scheduling battery charge/discharge across markets. Each strategy makes different trade-offs between speed, accuracy, and realism.
How Strategy Selection Works
Set the strategy in your API request:
{
"run": {
"strategy": "rolling_lp"
}
}Rolling LP (rolling_lp) — Default
The production default. Divides the simulation into overlapping windows and solves one LP per window, committing near-term decisions before rolling forward.
How It Works
- Look ahead
foresight_daysdays from the current position - Solve a single LP for the visible window across all enabled markets
- Commit the first
execute_daysdays of the solution - Update battery SoC and degradation, then roll forward
- Repeat until the simulation ends
Step through the rolling window process:
Foresight: 5 days · Execute: 3 days · Current solve: days 1–5
Parameters
| Parameter | Default | Range | Effect |
|---|---|---|---|
foresight_days | 3 | 1–30 | Days visible to the optimizer per window |
execute_days | = foresight_days | 1–foresight_days | Days committed before re-solving |
Tuning execute_days
Lower execute_days means more frequent re-optimization — better adaptation to price changes, but slower execution. For most cases the default (equal to foresight_days) works well.
Example
curl -X POST http://localhost:8000/api/simulate \
-H "Content-Type: application/json" \
-d '{
"batteryCapacityMwh": 10,
"batteryPowerMw": 5,
"run": {
"strategy": "rolling_lp",
"foresight_days": 5,
"execute_days": 3,
"enabled_markets": ["day_ahead", "intraday", "imbalance"]
}
}'Characteristics
- Speed: ~2–5 seconds per simulated year (~122 windows at 3-day foresight)
- Accuracy: High — captures most of the theoretical optimum
- Realism: Good — limited foresight prevents unrealistic perfect-knowledge decisions
- Degradation: Tracked between windows via
SimpleDegradationTracker - Terminal constraint: SoC is constrained at window boundaries to prevent over-depletion
Full LP (lp) — Perfect Foresight
Solves a single LP over the entire simulation period with complete knowledge of all future prices. This provides the theoretical maximum revenue — the ceiling that no realistic strategy can exceed.
How It Works
- Build one LP covering all T periods (e.g., 35,040 for one year at 15-minute resolution)
- Solve once with all prices known
- Extract the full schedule
- Track degradation post-hoc
Example
curl -X POST http://localhost:8000/api/simulate \
-H "Content-Type: application/json" \
-d '{
"batteryCapacityMwh": 10,
"batteryPowerMw": 5,
"run": {
"strategy": "lp",
"enabled_markets": ["day_ahead", "intraday"]
}
}'Characteristics
- Speed: ~5–15 seconds per simulated year (single large LP)
- Accuracy: Optimal — guaranteed maximum revenue
- Realism: None — assumes perfect knowledge of all future prices
- Use case: Benchmarking. Compare rolling_lp revenue against this ceiling to evaluate strategy quality
Not for production forecasts
Full LP revenue is unattainable in practice. Use it as a theoretical upper bound, not as an expected revenue figure. Typical rolling_lp captures 85–95% of the full LP value.
Sequential LP (sequential_lp) — Market-Aware
Models real-world market timing by solving separate LPs per market in gate-closure order. Decisions made at earlier stages are frozen before later markets are optimized.
Gate Closure Order
In Dutch electricity markets, trading decisions happen in a specific sequence:
How It Works
Walk through the stages interactively:
At each stage:
- Markets that have already been decided are frozen — their LP variables are locked (lower bound = upper bound = committed value)
- Markets not yet reachable are zeroed out — their variables are fixed to zero to prevent the optimizer from treating them as free energy sources
- The optimizer solves only for the active market(s) at this stage
- The solution is committed, and the process moves to the next stage
This runs inside the same rolling window framework as rolling_lp, so foresight and execution parameters apply identically.
Parameters
| Parameter | Default | Range | Effect |
|---|---|---|---|
foresight_days | 3 | 1–30 | Days visible to the optimizer per window |
execute_days | = foresight_days | 1–foresight_days | Days committed before re-solving |
Same parameters as rolling_lp
Sequential LP uses the same windowing mechanism. The only difference is that each window solves multiple staged LPs instead of a single combined LP.
Example
curl -X POST http://localhost:8000/api/simulate \
-H "Content-Type: application/json" \
-d '{
"batteryCapacityMwh": 10,
"batteryPowerMw": 5,
"run": {
"strategy": "sequential_lp",
"foresight_days": 3,
"execute_days": 1,
"enabled_markets": ["day_ahead", "intraday", "imbalance", "afrr", "fcr"]
}
}'Characteristics
- Speed: ~5–12 seconds per simulated year (~2–3x slower than rolling_lp due to multiple LP solves per window)
- Accuracy: Medium-high — slightly lower revenue than rolling_lp because later stages are constrained by earlier decisions
- Realism: Highest — models the actual sequence of market decisions a trader would face
- Best for: Evaluating realistic revenue when trading across all five markets
When to use sequential_lp
Sequential LP is most valuable when capacity markets (aFRR, FCR) are enabled alongside energy markets. If you're only trading day-ahead and intraday, the staging adds overhead with little benefit — use rolling_lp instead.
Choosing the Right Strategy
| Rolling LP | Full LP | Sequential LP | |
|---|---|---|---|
| Use case | Production simulations | Benchmarking | Multi-market realism |
| Foresight | Limited (configurable) | Perfect | Limited (configurable) |
| Market ordering | Simultaneous | Simultaneous | Gate-closure staged |
| Speed | ~2–5s/year | ~5–15s/year | ~5–12s/year |
| Revenue vs. optimal | 85–95% | 100% (theoretical) | 75–90% |
| When to use | Default choice | Compare against ceiling | aFRR/FCR + energy markets |
Price Scaling
All strategies support price scaling for scenario analysis — modeling conservative revenue estimates, price caps, or stressed market conditions. Scaling is applied to prices before the optimizer sees them.
How It Works
Energy markets and capacity markets are scaled differently:
Energy Markets — Percentile Scaling
For day-ahead, intraday, and imbalance prices, scaling compresses extreme prices toward their percentile thresholds. A value of 5.0 means the top and bottom 5% of prices are moved halfway toward the 5th/95th percentile boundary:
if price < P5: scaled = P5 - (P5 - price) × 0.5 # extremes pulled up
if price > P95: scaled = P95 + (price - P95) × 0.5 # extremes pulled down
else: scaled = price # middle 90% unchangedThis reduces the revenue from extreme price spikes while preserving the overall price structure — more realistic than hard clipping.
Drag the slider to see how different percentile values affect a sample day-ahead price curve:
Capacity Markets — Multiplier Scaling
For aFRR and FCR prices, the scaling value is a direct multiplier applied to all prices:
scaled = price × multiplierA multiplier of 0.8 reduces all capacity revenue by 20%. Use this to model scenarios where auction clearing prices decline.
Parameters
| Parameter | Type | Description |
|---|---|---|
price_scaling_percentile | float | Global percentile for all energy markets (e.g., 5.0) |
price_scaling_percentiles_per_market | dict | Per-market overrides (takes precedence over global) |
Valid market keys for price_scaling_percentiles_per_market:
| Key | Type | Value meaning |
|---|---|---|
day_ahead | Energy | Percentile threshold (e.g., 5.0) |
intraday | Energy | Percentile threshold |
imbalance_long | Energy | Percentile threshold |
imbalance_short | Energy | Percentile threshold |
afrr_capacity | Capacity | Multiplier (e.g., 0.8 = 80%) |
afrr_up_price | Capacity | Multiplier |
afrr_down_price | Capacity | Multiplier |
fcr_capacity | Capacity | Multiplier |
Examples
Global scaling — compress the top/bottom 5% of all energy prices:
{
"run": {
"strategy": "rolling_lp",
"price_scaling_percentile": 5.0
}
}Per-market scaling — conservative DA, aggressive capacity haircut:
{
"run": {
"strategy": "rolling_lp",
"price_scaling_percentiles_per_market": {
"day_ahead": 10.0,
"intraday": 5.0,
"afrr_capacity": 0.8,
"fcr_capacity": 0.7
}
}
}This clips the top/bottom 10% of DA prices, 5% of ID prices, reduces aFRR revenue by 20%, and FCR revenue by 30%.
When to use price scaling
Use price scaling for sensitivity analysis in financial models. Run the same simulation at different scaling levels (e.g., 0%, 5%, 10%) to understand how revenue depends on extreme price events. Capacity multipliers are useful for modeling declining auction prices over the project lifetime.
FCA — Flexible Connection Agreement
FCA constraints model real-world grid connection agreements that restrict when and how much the battery can charge or discharge. These are enforced as hard constraints in the LP — the optimizer cannot violate them.
How Constraints Combine
The effective charge limit at each 15-minute period is:
charge_limit[t] = min(gridChargingCapacity, monthlyLimit[month], hourlyLimit[hour])If solar or wind conditions fall outside the allowed range, charging (or discharging) is blocked entirely — the LP variables are forced to zero for that period.
Time-Based Grid Restrictions
Monthly Charge Limits (gridChargingCapacityByMonth)
An array of 12 MW values, one per month (January through December). Each value is an absolute power limit — not a multiplier.
{
"gridChargingCapacityByMonth": [
2.0, 2.0, 2.0,
1.5, 1.5, 1.0,
1.0, 1.0, 1.5,
2.0, 2.0, 2.0
]
}This restricts grid charging to 1.0 MW during summer (June-August) and allows 2.0 MW in winter. The optimizer sees a tighter constraint in summer months and adapts its schedule accordingly.
Hourly Charge Limits (gridChargingCapacityByHour)
An array of 24 MW values, one per hour of the day (0:00 through 23:00). Each value applies to all four 15-minute periods within that hour.
{
"gridChargingCapacityByHour": [
2.0, 2.0, 2.0, 2.0, 2.0, 2.0,
1.5, 1.5, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0, 1.5,
1.5, 2.0, 2.0, 2.0, 2.0, 2.0
]
}This limits charging to 1.0 MW during peak hours (8:00-17:00) and allows 2.0 MW overnight — typical for grid connections where the DSO restricts daytime import.
Combined Effect
When both monthly and hourly limits are set, the minimum of all three limits applies:
effective_limit = min(gridChargingCapacity, monthlyLimit[month], hourlyLimit[hour])Example: June at 10:00
gridChargingCapacity= 2.0 MWgridChargingCapacityByMonth[5]= 1.0 MW (June)gridChargingCapacityByHour[10]= 1.0 MW (10:00)- Effective limit = min(2.0, 1.0, 1.0) = 1.0 MW
Example: January at 02:00
gridChargingCapacity= 2.0 MWgridChargingCapacityByMonth[0]= 2.0 MW (January)gridChargingCapacityByHour[2]= 2.0 MW (02:00)- Effective limit = min(2.0, 2.0, 2.0) = 2.0 MW
Solar/Wind Weather Gates
These parameters gate charging and discharging operations based on real-time solar irradiance (W/m²) and wind speed (m/s) from the scenario's weather data. When conditions fall outside the allowed range, the LP variables for that period are forced to zero.
Charging Gates
| Parameter | Unit | Effect |
|---|---|---|
solarChargeMin | W/m² | Charging blocked when irradiance < threshold |
solarChargeMax | W/m² | Charging blocked when irradiance > threshold |
windChargeMin | m/s | Charging blocked when wind speed < threshold |
windChargeMax | m/s | Charging blocked when wind speed > threshold |
Discharging Gates
| Parameter | Unit | Effect |
|---|---|---|
solarDischargeMin | W/m² | Discharging blocked when irradiance < threshold |
solarDischargeMax | W/m² | Discharging blocked when irradiance > threshold |
windDischargeMin | m/s | Discharging blocked when wind speed < threshold |
windDischargeMax | m/s | Discharging blocked when wind speed > threshold |
All parameters default to None (no restriction). Both solar and wind conditions must be satisfied — if either is outside the range, the operation is blocked.
Typical Use Cases
"Only charge when renewables are producing" — restrict charging to periods with sufficient solar or wind output:
{
"solarChargeMin": 50,
"windChargeMin": 3.0
}The battery can only charge when solar irradiance exceeds 50 W/m² and wind speed exceeds 3 m/s.
"Don't discharge during peak solar" — prevent discharging when solar production is high (let solar feed the grid directly):
{
"solarDischargeMax": 200
}Discharging is blocked when irradiance exceeds 200 W/m², so the battery only discharges in low-solar periods.
Full FCA Example
A co-located solar+wind+BESS project with a restrictive grid connection:
{
"params": {
"bessCapacity": 10.0,
"bessPower": 5.0,
"gridChargingCapacity": 3.0,
"gridDischargingCapacity": 5.0,
"gridChargingCapacityByMonth": [
3.0, 3.0, 3.0, 2.5, 2.0, 1.5,
1.5, 1.5, 2.0, 2.5, 3.0, 3.0
],
"gridChargingCapacityByHour": [
3.0, 3.0, 3.0, 3.0, 3.0, 3.0,
2.0, 2.0, 1.5, 1.5, 1.5, 1.5,
1.5, 1.5, 1.5, 1.5, 2.0, 2.0,
2.0, 3.0, 3.0, 3.0, 3.0, 3.0
],
"solarChargeMin": 50,
"windChargeMin": 3.0,
"solarInstalledCapacity": 10.0,
"windInstalledCapacity": 5.0
},
"run": {
"strategy": "rolling_lp",
"enabled_markets": ["day_ahead", "intraday"]
}
}This configuration:
- Limits grid charging to 1.5 MW during summer peak hours
- Only allows charging when solar > 50 W/m² and wind > 3 m/s
- Allows full 5 MW discharge at all times
Performance Comparison
Solve times for a typical 10 MW / 20 MWh battery over 1 simulated year:
| Strategy | Markets | Windows | Solves | Total Time |
|---|---|---|---|---|
rolling_lp | DA only | 52 | 52 | ~1.5s |
rolling_lp | All 5 | 52 | 52 | ~3s |
lp | DA only | 1 | 1 | ~5s |
lp | All 5 | 1 | 1 | ~12s |
sequential_lp | All 5 | 52 | 208 (4 stages × 52) | ~8s |
sequential_lp | DA + ID + IMB | 52 | 156 (3 stages × 52) | ~6s |
Model reuse
The LP solver reuses model structure between windows, only updating price coefficients and bounds. This makes sequential solves much faster than building from scratch each time.