nexa-bidkit

Day-ahead and intraday auction bid generation for European power markets.

Day-ahead and intraday auction bid generation library for European power markets. DataFrame-first API for curve construction, block bids, and portfolio management, with EUPHEMIA-compatible validation and Nord Pool serialisation.

Python 3.11+ MIT License 1.0.0

pip install nexa-bidkit

GitHub | PyPI


Why this exists

Building bids for European power auctions is complex. The EUPHEMIA algorithm supports block bids, linked orders, exclusive groups, and merit-order curves, each with specific constraints. Getting the domain model wrong means rejected bids or unintended market exposure.

nexa-bidkit provides:

  • DataFrame-first curve construction — build price-quantity curves directly from your forecast or optimisation output
  • Correct EUPHEMIA bid type modelling — block bids, linked orders, exclusive groups
  • Pre-submission validation catching constraint violations before they reach the exchange
  • 15-minute MTU support natively across all bid types
  • Nord Pool serialisation — converts your bids to Nord Pool Auction API payloads

Quick start

Build a supply curve from a DataFrame

import pandas as pd
from decimal import Decimal
from datetime import datetime
from zoneinfo import ZoneInfo
from nexa_bidkit import CurveType, MTUDuration, MTUInterval, from_dataframe

# Your bid data
df = pd.DataFrame({
    "price": [10.5, 25.0, 45.0, 80.0],
    "volume": [50, 100, 75, 25],
})

# Define the MTU (15-minute interval)
mtu = MTUInterval.from_start(
    datetime(2026, 4, 1, 13, 0, tzinfo=ZoneInfo("Europe/Oslo")),
    MTUDuration.QUARTER_HOURLY
)

curve = from_dataframe(df, curve_type=CurveType.SUPPLY, mtu=mtu)

print(f"Total volume: {curve.total_volume} MW")
print(f"Price range: {curve.min_price} - {curve.max_price} EUR/MWh")

Build block bids

from decimal import Decimal
from datetime import datetime
from zoneinfo import ZoneInfo
from nexa_bidkit import (
    BiddingZone, Direction, DeliveryPeriod, MTUDuration,
    block_bid, indivisible_block_bid, linked_block_bid, exclusive_group,
)

# Four-hour delivery window, 15-minute MTUs
delivery = DeliveryPeriod(
    start=datetime(2026, 4, 1, 10, 0, tzinfo=ZoneInfo("Europe/Oslo")),
    end=datetime(2026, 4, 1, 14, 0, tzinfo=ZoneInfo("Europe/Oslo")),
    duration=MTUDuration.QUARTER_HOURLY,
)

# Divisible block bid — accepts partial fill down to 50%
peak_bid = block_bid(
    bidding_zone=BiddingZone.NO1,
    direction=Direction.SELL,
    delivery_period=delivery,
    price=Decimal("55.0"),
    volume=Decimal("100"),
    min_acceptance_ratio=Decimal("0.5"),
)

# Indivisible — must run, all-or-nothing
must_run = indivisible_block_bid(
    bidding_zone=BiddingZone.NO1,
    direction=Direction.SELL,
    delivery_period=delivery,
    price=Decimal("25.0"),
    volume=Decimal("50"),
)

# Linked bid — only accepted if parent is accepted
ramp_up = linked_block_bid(
    parent_bid_id=must_run.bid_id,
    bidding_zone=BiddingZone.NO1,
    direction=Direction.SELL,
    delivery_period=delivery,
    price=Decimal("35.0"),
    volume=Decimal("25"),
)

# Exclusive group — at most one of these is accepted
option_a = block_bid(BiddingZone.NO1, Direction.SELL, delivery, Decimal("40.0"), Decimal("150"))
option_b = block_bid(BiddingZone.NO1, Direction.SELL, delivery, Decimal("45.0"), Decimal("120"))
options = exclusive_group([option_a, option_b])

Supported bid types

Bid Type Description Supported
Simple curve orders Price-quantity pairs per MTU Yes
Block bids Fixed price/volume across multiple MTUs Yes
Indivisible block bids All-or-nothing block bids Yes
Linked block bids Parent-child dependencies Yes
Exclusive groups Mutually exclusive bid sets Yes

Key concepts

Price-quantity curves

Merit-order curves represent supply or demand. Supply curves are sorted ascending by price (cheapest generation first); demand curves descending.

from nexa_bidkit import merge_curves, clip_curve, filter_zero_volume, aggregate_by_price

# Combine bids from multiple plants
portfolio = merge_curves(
    [plant1_curve, plant2_curve],
    aggregation="sum"
)

# Clean and constrain
portfolio = filter_zero_volume(portfolio)
portfolio = clip_curve(
    portfolio,
    min_price=Decimal("0"),
    max_volume=Decimal("500"),
)
portfolio = aggregate_by_price(portfolio)  # Consolidate for smaller message size

Order book

Manage a portfolio of bids across zones and sessions:

from nexa_bidkit import (
    create_order_book, add_bid, add_bids,
    get_bids_by_zone, count_bids, total_volume_by_zone,
    update_all_statuses, orders_to_dataframe, BidStatus, BiddingZone,
)

book = create_order_book()
book = add_bids(book, [must_run, peak_bid, ramp_up])

no1_bids = get_bids_by_zone(book, BiddingZone.NO1)
volumes = total_volume_by_zone(book)
print(f"NO1 volume: {volumes[BiddingZone.NO1]} MW")

# Export to DataFrame for analysis
df = orders_to_dataframe(book)

Validation

Validate bids against EUPHEMIA rules before submission:

from nexa_bidkit import (
    validate_bid, validate_bids, validate_order_book_for_submission,
    get_validation_summary, EuphemiaValidationError,
)
from datetime import datetime
from zoneinfo import ZoneInfo

# Validate a single bid
try:
    validate_bid(peak_bid)
except EuphemiaValidationError as e:
    print(f"EUPHEMIA compliance error: {e}")

# Batch validation
results = validate_bids([must_run, peak_bid, ramp_up])
summary = get_validation_summary(results)
print(f"Pass rate: {summary['pass_rate']:.1f}%")

# Full pre-submission check (includes gate closure)
gate_closure = datetime(2026, 3, 31, 12, 0, tzinfo=ZoneInfo("Europe/Oslo"))
validate_order_book_for_submission(book, gate_closure_time=gate_closure)

Validation enforces:

  • EUPHEMIA rules: maximum curve steps (200), block duration limits
  • Data quality: minimum volumes (0.1 MW), reasonable price increments
  • Temporal constraints: gate closure deadlines, delivery periods within auction day

Nord Pool serialisation

Convert your bids to Nord Pool Auction API payloads. You supply a ContractIdResolver callable that maps (MTUInterval, BiddingZone) to Nord Pool contract IDs — call Nord Pool's products API to populate this at runtime.

from nexa_bidkit.nordpool import (
    simple_bid_to_curve_order,
    block_bid_to_block_list,
    order_book_to_nord_pool,
)

def resolve_contract(mtu, zone):
    # Call Nord Pool products API in production
    hour = mtu.start.hour
    return f"{zone.value}-{hour}"

# Convert a full order book
submission = order_book_to_nord_pool(
    book,
    auction_id="DA-2026-04-01",
    portfolio="my-portfolio",
    contract_id_resolver=resolve_contract,
)

print(f"Curve orders:        {len(submission.curve_orders)}")
print(f"Block orders:        {len(submission.block_orders)}")
print(f"Linked block orders: {len(submission.linked_block_orders)}")
print(f"Exclusive groups:    {len(submission.exclusive_group_orders)}")

Examples

Four Jupyter notebooks covering real-world scenarios are included in the repository:

Notebook Scenario
01_simple_hourly_bids.ipynb Hallingdal Wind Farm (NO2) — 24h supply bids with 15-min MTUs
02_block_bids.ipynb Borgholt CCGT (DE-LU) — startup cost recovery, exclusive operating modes
03_merit_order_curves.ipynb Fjord Energy aggregator — multi-asset portfolio merit order
04_order_book_and_validation.ipynb Solberg trading desk — end-to-end: order book, validation, Nord Pool export

Premium: Hosted Bid Optimisation

Coming Soon

For teams that want optimal bid portfolio construction:

  • MILP optimisation engine given asset constraints and price forecasts
  • Cloud-hosted solver with sub-minute solve times
  • Excel add-in for non-coding desk users

Pricing starts at EUR 99/month. Learn more.