Skip to content

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.