Skip to main content

Backtesting framework for European power markets

Project description

nexa-backtest

CI PyPI Python License: MIT

A backtesting framework built for European power markets. Not another equities backtester with energy bolted on.

Handles day-ahead auctions, intraday auctions, intraday continuous trading, 15-minute MTUs, block bids, gate closures, and exchange-specific matching rules. Runs your algo against historical data and tells you: did it make money? Did it beat VWAP?

Why this exists

Every backtesting framework out there assumes continuous order books, tick-by-tick data, and price-time priority. Energy markets work differently. You have auctions with gate closures, 96 quarter-hour products per day, block bids that span multiple hours, and matching algorithms that clear everything at once.

If you have tried backtesting an energy trading strategy with Zipline, Backtrader, or VectorBT, you know the pain. nexa-backtest is the tool those frameworks should have been.

Features

  • Purpose-built for energy: DA auctions, ID auctions, IDC continuous, 15-min MTUs
  • One interface, three engines: same algo code for backtesting, paper trading, and live trading
  • Two API levels: SimpleAlgo for quick experiments, @algo decorator for full control
  • Exchange adapters: Nord Pool, EPEX SPOT, EEX with feature detection and validation
  • Signal system: weather, price forecasts, load data, carbon prices, or anything custom
  • ML model support: ONNX, scikit-learn, PyTorch models via ctx.predict()
  • Smart data loading: DA data loaded entirely (tiny), IDC data windowed from Parquet (scalable)
  • Validation pipeline: ruff + mypy + exchange feature checks + look-ahead bias detection
  • Code protection: Cython/Nuitka compilation for IP-sensitive hosted environments

Installation

pip install nexa-backtest

With optional extras:

pip install nexa-backtest[pandas]     # DataFrame output
pip install nexa-backtest[plot]       # matplotlib/plotly charts
pip install nexa-backtest[ml]         # ONNX + scikit-learn model support
pip install nexa-backtest[data]       # nexa-marketdata integration
pip install nexa-backtest[live]       # nexa-connect for live trading
pip install nexa-backtest[all]        # everything

Quick start

Your first backtest (20 lines)

from datetime import date
from nexa_backtest import SimpleAlgo, TradingContext, Order, BacktestEngine

class BuyBelowForecast(SimpleAlgo):
    """Buy when DA clearing price is below the wind forecast signal."""

    def on_setup(self, ctx: TradingContext) -> None:
        self.subscribe_signal("da_price_forecast")
        self.subscribe_signal("wind_generation_forecast")

    def on_auction_open(self, ctx: TradingContext, auction) -> None:
        forecast = ctx.get_signal("da_price_forecast").value
        wind = ctx.get_signal("wind_generation_forecast").value

        if wind > 15_000:  # High wind expected, prices likely low
            ctx.place_order(Order.buy(
                product=auction.product_id,
                volume_mw=10,
                price_eur=forecast - 5.0,
            ))

    def on_fill(self, ctx: TradingContext, fill) -> None:
        ctx.log(f"Filled {fill.volume_mw} MW @ {fill.price_eur}")

# Run it
result = BacktestEngine(
    algo=BuyBelowForecast(),
    exchange="nordpool",
    start=date(2026, 3, 1),
    end=date(2026, 3, 31),
    products=["NO1_DA"],
    initial_capital=100_000,
).run()

print(result.summary())
# Total PnL: +12,340.50 EUR
# vs VWAP:   +3.2%
# Sharpe:    1.4
# Win rate:  62%
# Max DD:    -4,200.00 EUR

Full control with @algo

For quants who want to manage their own event loop:

from nexa_backtest import TradingContext, Order, algo

@algo(name="spread_scalper", version="1.0.0")
async def run(ctx: TradingContext) -> None:
    async for event in ctx.events():
        match event:
            case MarketDataUpdate(product_id=pid):
                book = ctx.get_orderbook(pid)
                spread = book.best_ask.price - book.best_bid.price
                if spread > 2.0:
                    ctx.place_order(Order.buy(
                        product=pid,
                        volume_mw=5,
                        price_eur=book.best_bid.price + 0.5,
                    ))

            case GateClosureWarning(product_id=pid, remaining=remaining):
                if remaining.total_seconds() < 300:
                    pos = ctx.get_position(pid)
                    if pos.net_mw != 0:
                        ctx.place_order(Order.market(
                            product=pid,
                            volume_mw=-pos.net_mw,
                        ))

Same algo, three modes

from nexa_backtest import BacktestEngine, PaperEngine, LiveEngine

algo = BuyBelowForecast()

# Backtest: historical replay, simulated matching
result = BacktestEngine(algo=algo, exchange="nordpool", ...).run()

# Paper: live data, simulated matching, no real money
paper = PaperEngine(algo=algo, exchange="nordpool", ...).start()

# Live: real data, real exchange, real money
live = LiveEngine(algo=algo, exchange="nordpool", credentials=...).start()

The algo code is identical in all three cases. The only thing that changes is which engine you pass it to.

Using signals

from nexa_backtest.signals import (
    DayAheadPriceSignal,
    WindForecastSignal,
    LoadForecastSignal,
)

result = BacktestEngine(
    algo=algo,
    exchange="nordpool",
    start=date(2026, 3, 1),
    end=date(2026, 3, 31),
    signals=[
        DayAheadPriceSignal(zone="NO1"),
        WindForecastSignal(zone="NO1", provider="meteomatics"),
        LoadForecastSignal(zone="NO1"),
    ],
).run()

Custom signals implement SignalProvider:

from nexa_backtest.signals import SignalProvider, SignalSchema, SignalValue

class MyForecast(SignalProvider):
    name = "my_forecast"
    schema = SignalSchema(
        name="my_forecast",
        dtype=float,
        frequency=timedelta(minutes=15),
        description="Internal price forecast",
        unit="EUR/MWh",
    )

    def __init__(self, data_path: str):
        self._data = pd.read_parquet(data_path)

    def get_value(self, timestamp: datetime) -> SignalValue:
        return SignalValue(
            timestamp=timestamp,
            value=self._data.loc[timestamp, "forecast"],
        )

Using ML models

from nexa_backtest.models import ModelRegistry, ONNXModel

models = ModelRegistry()
models.register(ONNXModel(
    name="price_predictor",
    path="models/price_xgboost.onnx",
    input_schema={"wind": float, "load": float, "hour": int},
    output_schema={"price_forecast": float},
))

result = BacktestEngine(
    algo=algo,
    exchange="nordpool",
    models=models,
    ...
).run()

# In your algo:
prediction = ctx.predict("price_predictor", {
    "wind": ctx.get_signal("wind_forecast").value,
    "load": ctx.get_signal("load_forecast").value,
    "hour": ctx.now().hour,
})

Validation

Catch bugs before they cost you a 10-minute backtest run:

$ nexa validate my_algo.py --exchange nordpool

Step 1/6: Syntax Check (ruff)
  [PASS] No syntax errors

Step 2/6: Type Check (mypy --strict)
  [PASS] TradingContext protocol satisfied

Step 3/6: Interface Compliance
  [PASS] Required hooks implemented

Step 4/6: Exchange Feature Compatibility
  [PASS] All order types supported by Nord Pool

Step 5/6: Look-ahead Bias Detection
  [PASS] No future data access detected

Step 6/6: Resource Safety
  [WARN] Line 78: time.sleep() detected. Use ctx.wait() instead.

Result: PASSED (1 warning)

PnL analysis

result = engine.run()

# Summary
print(result.summary())

# VWAP comparison
print(result.vwap_analysis())
# Period      | Your Avg | VWAP   | Edge    | Volume
# 2026-03-01  | 42.30    | 43.15  | +0.85   | 120 MW
# 2026-03-02  | 38.90    | 39.20  | +0.30   | 95 MW
# TOTAL       | 44.20    | 44.85  | +0.65   | 3,240 MW

# Export
result.to_parquet("results/march.parquet")
result.to_html("results/march.html")  # full report with charts
trades_df = result.trades.to_dataframe()

Historical data format

Data is stored as Parquet files. DA data is tiny (load entirely). IDC data is large (windowed replay).

data/
  nordpool/
    da_clearing_prices/
      NO1_2025.parquet              # ~1.7 MB, loaded entirely
      NO1_2026.parquet
    idc_orderbook_snapshots/
      NO1_2026_01.parquet           # ~800 MB, windowed replay
      NO1_2026_02.parquet
    idc_events/
      NO1_2026_01.parquet           # ~125 MB, windowed replay
    idc_trades/
      NO1_2026_01.parquet           # ~17 MB, windowed replay
  signals/
    wind_forecast/
      NO1_2026.parquet              # ~50 MB, loaded entirely

Use nexa-marketdata to fetch and cache historical data, then nexa-backtest replays it:

from nexa_backtest.data import NexaMarketdataLoader

loader = NexaMarketdataLoader(
    source="nordpool",
    zones=["NO1", "NO2"],
    start=date(2025, 10, 1),
    end=date(2026, 3, 31),
)
# Downloads and caches locally as Parquet

Exchange support

Exchange DA Auction ID Auction IDC Continuous Status
Nord Pool Yes Yes Yes Planned
EPEX SPOT Yes Yes Yes Planned
EEX Yes - - Planned

Each exchange adapter declares its capabilities. The validation pipeline checks your algo uses only supported features before runtime:

$ nexa validate my_algo.py --exchange epex_spot

  [FAIL] Feature compatibility:
    - Line 42: Order.block_bid() used, but EPEX SPOT continuous
      does not support block bids.

  1 error. Fix before running.

Code protection

For hosted environments where you do not want to share source code:

# Compile to native binary (Cython)
$ nexa compile my_algo.py --output my_algo.so

# Upload compiled binary, not source
$ nexa upload my_algo.so --config backtest.yaml
Approach IP Protection Performance Best for
Self-hosted N/A (local) Fastest Most users
Cython Good Fast Hosted backtesting
Nuitka Very good Fast Maximum protection
Container Excellent Slower Enterprise

Phase Nexa ecosystem

nexa-backtest integrates with the rest of the Phase Nexa toolkit:

nexa-marketdata ---- fetches data ------> nexa-backtest (replay)
nexa-bidkit -------- bid construction --> nexa-backtest (order types)
nexa-connect ------- exchange comms ----> nexa-backtest (live engine)
nexa-forecast ------ ML models ---------> nexa-backtest (signals/models)
nexa-mcp ----------- LLM interface -----> nexa-backtest (run from chat)

Each piece is independently useful. Together they form a complete trading development environment.

Performance

Scenario Time Peak memory
1 year, DA, 1 zone < 1 second ~50 MB
1 year, DA, 10 zones < 5 seconds ~200 MB
1 year, IDC, 1 zone 1-3 minutes ~300 MB
1 year, IDC, 5 zones 3-8 minutes ~500 MB
4 algos shared, IDC, 5 zones 5-10 minutes ~700 MB

Times assume Parquet on local SSD/NVMe.

Contributing

See CONTRIBUTING.md for development setup, coding standards, and the PR process.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nexa_backtest-0.1.0b1.tar.gz (18.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nexa_backtest-0.1.0b1-py3-none-any.whl (18.2 kB view details)

Uploaded Python 3

File details

Details for the file nexa_backtest-0.1.0b1.tar.gz.

File metadata

  • Download URL: nexa_backtest-0.1.0b1.tar.gz
  • Upload date:
  • Size: 18.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for nexa_backtest-0.1.0b1.tar.gz
Algorithm Hash digest
SHA256 47281ff08b61d3717a140885fa8bb17013c1a4cdbbc66723fa42e4db013bcdae
MD5 9cf9031488414239b0350e8f0233bf25
BLAKE2b-256 4beeb7fbcecf7e012fe6971508de7f84dc922980f388eee13d6b284c7a8e6f58

See more details on using hashes here.

Provenance

The following attestation bundles were made for nexa_backtest-0.1.0b1.tar.gz:

Publisher: publish.yaml on phasenexa/nexa-backtest

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file nexa_backtest-0.1.0b1-py3-none-any.whl.

File metadata

File hashes

Hashes for nexa_backtest-0.1.0b1-py3-none-any.whl
Algorithm Hash digest
SHA256 c60ad3f0b3b2d2365a847fe194646a4c8f76b1dba83195abe18425c1076ce46e
MD5 99ea31cece38b5ccceed967e904ef89e
BLAKE2b-256 bf7af342caf4a7d89abbc1b7d5bad1f248fe0a54b6a30bc49f3b037bca4707c1

See more details on using hashes here.

Provenance

The following attestation bundles were made for nexa_backtest-0.1.0b1-py3-none-any.whl:

Publisher: publish.yaml on phasenexa/nexa-backtest

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page