Skip to content

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.

Rolling LP
~2-5s / year
Production default. Rolling foresight window with periodic re-optimization.
Full LP
~5-15s / year
Perfect foresight benchmark. Optimal but unrealistic — knows all future prices.
Sequential LP
~5-12s / year
Multi-stage optimization following real market gate-closure order. Most realistic.

How Strategy Selection Works

Set the strategy in your API request:

json
{
  "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

  1. Look ahead foresight_days days from the current position
  2. Solve a single LP for the visible window across all enabled markets
  3. Commit the first execute_days days of the solution
  4. Update battery SoC and degradation, then roll forward
  5. Repeat until the simulation ends

Step through the rolling window process:

Window 1 / 9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Committed Execute window Foresight (solved, not committed) Future

Foresight: 5 days · Execute: 3 days · Current solve: days 1–5

Parameters

ParameterDefaultRangeEffect
foresight_days31–30Days visible to the optimizer per window
execute_days= foresight_days1–foresight_daysDays 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

bash
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

  1. Build one LP covering all T periods (e.g., 35,040 for one year at 15-minute resolution)
  2. Solve once with all prices known
  3. Extract the full schedule
  4. Track degradation post-hoc

Example

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

Stage 1: Capacity Auctions
~1-2 days before delivery
Optimizing
aFRR
Optimizing
FCR
Pending
Day-Ahead
Pending
Intraday
Pending
Imbalance
Capacity markets clear first. The optimizer commits MW bids for aFRR and FCR, reserving battery headroom.
1
2
3
4
Optimizing Frozen (committed) Pending

At each stage:

  1. Markets that have already been decided are frozen — their LP variables are locked (lower bound = upper bound = committed value)
  2. Markets not yet reachable are zeroed out — their variables are fixed to zero to prevent the optimizer from treating them as free energy sources
  3. The optimizer solves only for the active market(s) at this stage
  4. 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

ParameterDefaultRangeEffect
foresight_days31–30Days visible to the optimizer per window
execute_days= foresight_days1–foresight_daysDays 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

bash
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 LPFull LPSequential LP
Use caseProduction simulationsBenchmarkingMulti-market realism
ForesightLimited (configurable)PerfectLimited (configurable)
Market orderingSimultaneousSimultaneousGate-closure staged
Speed~2–5s/year~5–15s/year~5–12s/year
Revenue vs. optimal85–95%100% (theoretical)75–90%
When to useDefault choiceCompare against ceilingaFRR/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% unchanged

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

0% — 20%
Prices below P5 and above P95 are pulled halfway toward their thresholds. The middle 90% of prices are untouched.

Capacity Markets — Multiplier Scaling

For aFRR and FCR prices, the scaling value is a direct multiplier applied to all prices:

scaled = price × multiplier

A multiplier of 0.8 reduces all capacity revenue by 20%. Use this to model scenarios where auction clearing prices decline.

Parameters

ParameterTypeDescription
price_scaling_percentilefloatGlobal percentile for all energy markets (e.g., 5.0)
price_scaling_percentiles_per_marketdictPer-market overrides (takes precedence over global)

Valid market keys for price_scaling_percentiles_per_market:

KeyTypeValue meaning
day_aheadEnergyPercentile threshold (e.g., 5.0)
intradayEnergyPercentile threshold
imbalance_longEnergyPercentile threshold
imbalance_shortEnergyPercentile threshold
afrr_capacityCapacityMultiplier (e.g., 0.8 = 80%)
afrr_up_priceCapacityMultiplier
afrr_down_priceCapacityMultiplier
fcr_capacityCapacityMultiplier

Examples

Global scaling — compress the top/bottom 5% of all energy prices:

json
{
  "run": {
    "strategy": "rolling_lp",
    "price_scaling_percentile": 5.0
  }
}

Per-market scaling — conservative DA, aggressive capacity haircut:

json
{
  "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.

json
{
  "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.

json
{
  "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 MW
  • gridChargingCapacityByMonth[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 MW
  • gridChargingCapacityByMonth[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

ParameterUnitEffect
solarChargeMinW/m²Charging blocked when irradiance < threshold
solarChargeMaxW/m²Charging blocked when irradiance > threshold
windChargeMinm/sCharging blocked when wind speed < threshold
windChargeMaxm/sCharging blocked when wind speed > threshold

Discharging Gates

ParameterUnitEffect
solarDischargeMinW/m²Discharging blocked when irradiance < threshold
solarDischargeMaxW/m²Discharging blocked when irradiance > threshold
windDischargeMinm/sDischarging blocked when wind speed < threshold
windDischargeMaxm/sDischarging 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:

json
{
  "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):

json
{
  "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:

json
{
  "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:

StrategyMarketsWindowsSolvesTotal Time
rolling_lpDA only5252~1.5s
rolling_lpAll 55252~3s
lpDA only11~5s
lpAll 511~12s
sequential_lpAll 552208 (4 stages × 52)~8s
sequential_lpDA + ID + IMB52156 (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.