Getting started¶
You give Helyx three files and a short request; Helyx runs its intraday trading algorithm on your positions against the real Nord Pool order book, day by day, and gives you back a CSV per day with what it would have earned. You never see Helyx's market data or code; Helyx never sees anything but the files you push. The whole loop is six commands, and a first run takes about two hours from request to result.
What you need before you start: the JSON key file Helyx sent you, the name of your bucket (helyx-client-<you>), Python 3.11 or later, and your data for the period you want to test.
Step 1 — Set up¶
Install the helyx command and tell it who you are. Once per machine.
pip install keyring keyrings.google-artifactregistry-auth
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/your-key.json
pip install helyx-sdk --extra-index-url https://europe-west4-python.pkg.dev/helyx-intraday-prod/helyx-sdk/simple/
export HELYX_BUCKET=helyx-client-esg
Check it works:
helyx coverage # the dates you can backtest
helyx policies # the algorithm versions you can pick and the settings you can change
helyx docs # this guide, for the version you have installed
helyx coverage prints a start date, an end date and any gaps. A run has to fit inside that. Put the two export lines in your shell profile so you do not have to repeat them.
Step 2 — Prepare three files¶
A backtest needs to know what you wanted to trade, what you had already sold day-ahead, and what you actually produced. Three files, one each, all on 15-minute periods in UTC.
| File | What it is | Format | Columns |
|---|---|---|---|
pm_targets.jsonl |
your Position Manager's instructions: for each delivery period, the net volume to trade and the price limits, as they were revised over time | JSON Lines (one JSON object per line) — nothing else is accepted | see below |
da_cleared.csv |
what you sold or bought in the day-ahead auction | CSV or Parquet | delivery_from_utc, cleared_mwh |
delivered.csv |
what you actually produced (metered) | CSV or Parquet | delivery_from_utc, delivered_mwh |
Signs, the thing most people get wrong once: cleared_mwh is negative when you sold day-ahead (a solar plant selling its forecast has negative numbers all day). delivered_mwh is positive when you produced. In the PM file, quantity_mw is negative to sell, positive to buy.
One line of pm_targets.jsonl, wrapped here for reading:
{"portfolio_id": "my_portfolio_nl",
"delivery_from_utc": "2026-03-02 12:00:00", "delivery_to_utc": "2026-03-02 12:15:00",
"created_utc": "2026-03-02 06:00:00",
"target": [{"quantity_mw": -1.5, "min_price_eur_per_mwh": 20.0, "max_price_eur_per_mwh": 4000.0}],
"da_position_delta": [{"reference_time": "da", "forecast": -1.5, "schedule": 0.0, "availability": 0.0}]}
Read it as: "at 06:00 UTC, for the 12:00 period, sell 1.5 MW but not below 20 EUR/MWh; this comes from a forecast change." A later line for the same period with a later created_utc is a revision and replaces the earlier target. target can hold several rungs (sell 1.5 MW above 20, a further 0.5 MW above 5); their quantities add up to the net target. created_utc must be before delivery_from_utc.
The cleared and delivered files must cover every period in the PM file, on a complete 15-minute grid with no duplicates. The da_cleared file must be the one that belongs with the PM stream: if the positions were built against a different day-ahead bid, the result is wrong without any warning, because nothing can check that.
Step 3 — Validate¶
Before uploading anything, run the same checks Helyx will run:
helyx validate --pm-targets pm_targets.jsonl --da-cleared da_cleared.csv --delivered delivered.csv
Each file gets a list of [ok] / [FAIL] lines and a VALID or INVALID verdict. A FAIL line says what is wrong ("12 periods missing between …", "3 revisions created at/after delivery start"). Fix the file and run it again until everything is VALID. A file that passes here passes there.
Step 4 — Push¶
Upload each file under a name you choose. The name is how you refer to it later, so pick names that say what the file is.
helyx push delivered delivered.csv --name realised
helyx push da_cleared da_cleared.csv --name actual_da
helyx push pm_targets pm_targets.jsonl --name improved_floor20
Each push validates first and refuses an invalid file. Push delivered once and reuse it in every run; push a new da_cleared when the day-ahead bidding changes; push a new pm_targets per strategy. If you change a file, push it under a new name rather than over the old one, so earlier runs stay comparable.
Step 5 — Request a run¶
A run is one line: which three files, which algorithm version, which dates, and an id you choose.
helyx request --id esg-2026-09-24-floor20 \
--pm-targets improved_floor20 --da-cleared actual_da --delivered realised \
--policy v1.3.0 --start 2026-03-01 --end 2026-05-31
The id names the run and its results folder, so make it descriptive (date, strategy, variant). Use a new id for every new run: an id already used with a different request is refused. The dates are inclusive UTC days inside what helyx coverage shows; a week, six days or three months are all fine, and cost scales with the number of days.
Then wait. Helyx picks requests up once an hour, so:
helyx runs # every run and its state
helyx status esg-2026-09-24-floor20 # one run, in detail
| State | Meaning |
|---|---|
| (unknown request) | submitted, not yet picked up — up to an hour |
queued |
accepted and frozen; your files as they were at that moment are what will run |
deferred |
you hit the daily limit; it will be accepted automatically later, nothing to do |
rejected |
something was wrong; reason says what — fix and submit under a new id |
running |
the simulation is running: about 12 minutes per day, in parallel, so a 92-day run takes about 80 minutes |
done |
results are in your outbox |
failed |
it broke on Helyx's side; the reason is in the status, and Helyx is notified |
End to end, expect two to three hours for a three-month run, most of it waiting for the hourly pickup.
Step 6 — Fetch and read the results¶
helyx fetch esg-2026-09-24-floor20 ./results
That downloads results/esg-2026-09-24-floor20/:
| File | What it is |
|---|---|
economics_2026-03-01.csv … one per day |
96 rows, one per 15-minute period, the full accounting of that period |
economics_monthly.csv |
one row per day plus a TOTAL row per month and for the run — start here |
run_parameters.json |
exactly what ran: your files (by name and hash), every setting, the algorithm version |
validation.json, status.json |
the checks that were run, and the state |
The number you are after is id_value_add_eur: how much better (or worse) the period settled with intraday trading than it would have without it. It is C − B, where:
- A = your day-ahead cash (what you sold day-ahead × the day-ahead price),
- B = A plus the imbalance you would have paid or received with no intraday trading,
- C = A plus the intraday trades plus the imbalance on what was left.
All cash is received, in EUR: sales positive, purchases negative. B is the same in every run on the same files, so id_value_add_eur is the number to compare between runs. It is not your P&L: subsidies are outside it.
Other columns worth knowing in the day files: dah_forecast_mw (what you had sold day-ahead, positive), actual_mw (what you produced), delta_mw (produced minus sold: positive = long, negative = short, before any intraday trade), id_sell_mw / id_sell_vwap and id_buy_mw / id_buy_vwap (what the algorithm sold and bought, at what average price), dah_price, and the two imbalance prices with imb_price_applied (the one your final position was settled at). Days are UTC; cet is only a clock label.
In a notebook:
import pandas as pd, glob
run = "results/esg-2026-09-24-floor20"
monthly = pd.read_csv(f"{run}/economics_monthly.csv")
print(monthly[monthly.delivery_date.str.contains("TOTAL")][["delivery_date", "id_sell_mwh", "id_buy_mwh", "id_value_add_eur"]])
days = pd.concat(pd.read_csv(f) for f in sorted(glob.glob(f"{run}/economics_2026-*.csv")))
days["delivery_start_utc"] = pd.to_datetime(days.delivery_start_utc)
daily = days.groupby("delivery_date").id_value_add_eur.sum()
daily.plot(kind="bar", title="value add per day, EUR")
# the hour of day that earns or costs the most
days.groupby(days.delivery_start_utc.dt.hour).id_value_add_eur.sum().plot(kind="bar")
To compare two runs, load both economics_monthly.csv, join on delivery_date, and look at the difference in id_value_add_eur; then open the two run_parameters.json side by side — only the settings you changed should differ.
Why did a day trade nothing? helyx status <id> shows, per run, the count of decisions by reason. BELOW_MIN_CORRECTION means every position that day was a forecast correction smaller than the deadband, which the policy holds by design (see deadband_mw below); PRICE_FLOOR means the market never reached your floor; DA_PRICE_GATED and HELD_BACK are your own rules from Step 7. A day with only such reasons is the algorithm following its instructions, not a failure.
Step 7 — Change a setting and run again¶
The algorithm is fixed per version; a handful of settings are yours to change. Put them in a small YAML file, push it under a name, and name it in the request with --config. Anything you leave out keeps the version's default, and the run's run_parameters.json shows every value that applied.
schema_version: "1"
deadband_mw: 1.0
flush_minutes: 0
fee_eur_mwh: 0.10
tags:
schedule:
hold_back_mw: 1.0
da_price_gate:
sell_only_below: -5
helyx push config my_settings.yaml --name gate5-holdback1
helyx request --id esg-2026-09-24-gate5 --config gate5-holdback1 --policy v1.3.0 \
--pm-targets improved_floor20 --da-cleared actual_da --delivered realised --start 2026-03-01 --end 2026-05-31
What each setting means, in plain words. Positions carry a tag from what caused them: forecast_update (a forecast changed), schedule (a block you committed to day-ahead), availability_update (the plant's availability changed). The per-tag settings apply to positions with that tag.
| Setting | In plain words | Default (v1.3.0) |
|---|---|---|
deadband_mw |
Ignore forecast corrections smaller than this until close to gate, because small corrections often reverse before they matter. It is about corrections, not about leaving volume: it never keeps a MW untraded on purpose. | 1.0 MW |
flush_minutes |
How many minutes before the gate the deadband is switched off so the rest gets worked. 0 = never: whatever is under the deadband stays untraded and settles as imbalance. |
0 |
fee_eur_mwh |
The fee you pay per MWh traded; a trade has to be worth it after the fee. | 0.10 |
tags.<tag>.hold_back_mw |
Always leave the last N MW of a position untraded, as a buffer against forecast error. This is the setting for "don't trade the last MW". | 0 (off) |
tags.<tag>.da_price_gate |
sell_only_below: x — only sell on periods whose day-ahead price is below x EUR/MWh (buy_only_above: y for buying). The day-ahead price is known before intraday trading starts, so this is a fair rule. Expect it to remove bad days and some good ones; check both. |
off |
tags.<tag>.default_window_minutes |
Over how long a position is worked when the algorithm has no creation time for it (yours always have one, so this rarely matters). | 1440 / 60 / 240 |
tags.<tag>.pacing_curve |
How fast to work a position over its window, as points [fraction of time, fraction done]. [[0,0],[1,1]] is evenly; null is "as the market allows". |
per tag |
tags.<tag>.urgency_schedule |
How much more aggressive to get as gate closure approaches, as points [minutes to gate, urgency 0–1]. |
per tag |
Run the baseline once, change one setting, run again, and compare id_value_add_eur per day. Two runs with the same files and the same settings give byte-identical results; the difference between two runs is only ever what you changed. The settings you can see with helyx policies are the complete list; anything else is refused.
When something goes wrong¶
A request is either rejected immediately (helyx status shows rejected and a reason) or it runs. You never wait an hour to learn a file was wrong.
reason says… |
What it means | Do this |
|---|---|---|
market data coverage / no coverage gaps in period |
your dates are outside what Helyx has, or hit a gap | helyx coverage; shorten the period |
pm_targets 'x' not found in inbox |
you named a file that was never pushed under that name | helyx push it, or fix the name |
da_cleared covers the pm_targets periods |
your cleared or delivered file does not cover every period the PM file wants to trade | extend the file, re-push under a new name |
schema: … Additional properties are not allowed |
a setting in your config that is not on the list | helyx policies; remove it |
pm: created before delivery |
a revision is dated after the period it is for | fix the timestamps; this would be look-ahead |
request_id unused: … REQUEST_ID_CONFLICT |
you reused an id with a different request | pick a new id |
daily cap (state deferred, not rejected) |
more than six runs in one day | nothing; it runs automatically |
failed after running |
something broke on Helyx's side | Helyx is notified; you can resubmit under a new id or wait for them |
If helyx validate says VALID but the request is rejected, the reason is one of the checks only Helyx can do (coverage, the settings list, the pairing of your three files); the message names it. If in doubt, send Helyx the request id — everything about a run is recorded under it.