Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/commands/checks.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
Run the following checks:

1. All tests pass using `make ci` (lint, typechecks, test and testnotebooks etc.)
1. Execute all notebooks `make execute-notebooks`
1. Review the README file, check nothing major is missing, suggest additions if something is identified
1. Review the README file, check nothing major is missing, suggest additions if something is identified
73 changes: 71 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ except ValidationError as e:
```

The validation module enforces:

- **EUPHEMIA rules**: Maximum curve steps (200), block duration limits (1-24 hours)
- **Data quality**: Minimum volumes (0.1 MW), reasonable price increments
- **Temporal constraints**: Gate closure deadlines, delivery periods within auction day
Expand Down Expand Up @@ -383,6 +384,74 @@ print(f"Linked block orders: {len(submission.linked_block_orders)}")
print(f"Exclusive groups: {len(submission.exclusive_group_orders)}")
```

### Submitting to EXAA

The `exaa` module converts your bids into EXAA Trading API order submission payloads.
EXAA groups bids by trade account. You supply an `account_id` string and a `ProductIdResolver`
callable to map MTU intervals to EXAA product IDs.

Unlike Nord Pool, EXAA's volume sign convention is **positive = buy, negative = sell**.
Linked block bids and exclusive groups are not supported by EXAA and will raise `ValueError`.

#### Hourly and 15-minute bids from an OrderBook

```python
from nexa_bidkit.exaa import (
order_book_to_exaa,
standard_hourly_product_id,
ExaaOrderType,
)
from nexa_bidkit import create_order_book, add_bids

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

# Use standard product ID helpers, or supply your own resolver
# from the auction's products API response.
payload = order_book_to_exaa(
book,
account_id="APTAP1",
product_id_resolver=standard_hourly_product_id,
order_type=ExaaOrderType.STEP,
)

# Serialise to JSON for the EXAA API (uses camelCase aliases)
print(payload.model_dump(by_alias=True))
# {
# "units": {"price": "EUR", "volume": "MWh/h"},
# "orders": [{
# "accountID": "APTAP1",
# "hourlyProducts": {"typeOfOrder": "STEP", "products": [...]},
# ...
# }]
# }
```

#### Block bids

```python
from nexa_bidkit.exaa import order_book_to_exaa, standard_hourly_product_id

def my_block_resolver(period):
# Map delivery periods to EXAA block product IDs from the auction response
return "bEXAbase (01-24)"

payload = order_book_to_exaa(
book,
account_id="APTAP1",
product_id_resolver=standard_hourly_product_id,
block_product_resolver=my_block_resolver,
)
```

The `exaa` module supports:

- **Hourly products**: `SimpleBid` with `MTUDuration.HOURLY` → `hourlyProducts`
- **15-minute products**: `SimpleBid` with `MTUDuration.QUARTER_HOURLY` → `15minProducts`
- **Block products**: `BlockBid` (indivisible bids map to `fillOrKill=true`) → `blockProducts`
- **ORDER types**: `STEP` (default) or `LINEAR` interpolation
- **Market orders**: `PriceVolumePair(price="M", volume=100)` for market orders

## Examples

The `examples/` directory contains Jupyter notebooks covering real-world European power market
Expand Down Expand Up @@ -503,7 +572,7 @@ gh pr create --title "chore: bump version to 1.0.0b1" --body "Version bump for b
# - Tag: v1.0.0b1 (target: main)
# - Check "This is a pre-release"
# - Publish release
# - Delete the chore branchs
# - Delete the chore branches
```

### Publishing a stable release
Expand All @@ -526,7 +595,7 @@ gh pr create --title "chore: bump version to 1.0.0" --body "Version bump for sta
# - Tag: v1.0.0 (target: main)
# - Do NOT check "This is a pre-release"
# - Publish release
# - Delete the chore branchs
# - Delete the chore branches
```

### What happens next
Expand Down
151 changes: 94 additions & 57 deletions examples/01_simple_hourly_bids.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@
"id": "a1b2c3d4-0001-0002-0001-000000000001",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T08:25:52.113327Z",
"iopub.status.busy": "2026-03-15T08:25:52.113174Z",
"iopub.status.idle": "2026-03-15T08:25:52.767880Z",
"shell.execute_reply": "2026-03-15T08:25:52.767595Z"
"iopub.execute_input": "2026-03-26T16:04:10.193771Z",
"iopub.status.busy": "2026-03-26T16:04:10.193592Z",
"iopub.status.idle": "2026-03-26T16:04:10.856064Z",
"shell.execute_reply": "2026-03-26T16:04:10.855788Z"
}
},
"outputs": [
Expand Down Expand Up @@ -91,10 +91,10 @@
"id": "a1b2c3d4-0001-0004-0001-000000000001",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T08:25:52.769211Z",
"iopub.status.busy": "2026-03-15T08:25:52.769083Z",
"iopub.status.idle": "2026-03-15T08:25:52.772565Z",
"shell.execute_reply": "2026-03-15T08:25:52.772334Z"
"iopub.execute_input": "2026-03-26T16:04:10.857287Z",
"iopub.status.busy": "2026-03-26T16:04:10.857181Z",
"iopub.status.idle": "2026-03-26T16:04:10.862715Z",
"shell.execute_reply": "2026-03-26T16:04:10.862400Z"
}
},
"outputs": [
Expand Down Expand Up @@ -164,10 +164,10 @@
"id": "a1b2c3d4-0001-0006-0001-000000000001",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T08:25:52.773784Z",
"iopub.status.busy": "2026-03-15T08:25:52.773690Z",
"iopub.status.idle": "2026-03-15T08:25:52.776425Z",
"shell.execute_reply": "2026-03-15T08:25:52.776162Z"
"iopub.execute_input": "2026-03-26T16:04:10.863880Z",
"iopub.status.busy": "2026-03-26T16:04:10.863815Z",
"iopub.status.idle": "2026-03-26T16:04:10.865901Z",
"shell.execute_reply": "2026-03-26T16:04:10.865704Z"
}
},
"outputs": [
Expand All @@ -186,10 +186,30 @@
"\n",
"# Hourly wind forecast (MW) — typical winter wind profile for NO2\n",
"hourly_forecast_mw = [\n",
" 55, 58, 62, 70, 75, 78, # 00:00–05:00 (building)\n",
" 80, 82, 85, 88, 90, 88, # 06:00–11:00 (morning peak)\n",
" 85, 80, 75, 70, 68, 65, # 12:00–17:00 (afternoon lull)\n",
" 60, 55, 52, 50, 48, 50, # 18:00–23:00 (evening wind returns)\n",
" 55,\n",
" 58,\n",
" 62,\n",
" 70,\n",
" 75,\n",
" 78, # 00:00–05:00 (building)\n",
" 80,\n",
" 82,\n",
" 85,\n",
" 88,\n",
" 90,\n",
" 88, # 06:00–11:00 (morning peak)\n",
" 85,\n",
" 80,\n",
" 75,\n",
" 70,\n",
" 68,\n",
" 65, # 12:00–17:00 (afternoon lull)\n",
" 60,\n",
" 55,\n",
" 52,\n",
" 50,\n",
" 48,\n",
" 50, # 18:00–23:00 (evening wind returns)\n",
"]\n",
"\n",
"assert len(hourly_forecast_mw) == 24, \"Need 24 hourly forecasts\"\n",
Expand All @@ -198,13 +218,13 @@
"forecast_mw = [mw for mw in hourly_forecast_mw for _ in range(4)]\n",
"\n",
"# Price tiers (EUR/MWh)\n",
"PRICE_TIER_1 = Decimal(\"10.00\") # firm low-cost\n",
"PRICE_TIER_2 = Decimal(\"35.00\") # mid-range\n",
"PRICE_TIER_3 = Decimal(\"55.00\") # marginal\n",
"PRICE_TIER_1 = Decimal(\"10.00\") # firm low-cost\n",
"PRICE_TIER_2 = Decimal(\"35.00\") # mid-range\n",
"PRICE_TIER_3 = Decimal(\"55.00\") # marginal\n",
"\n",
"print(f\"Peak forecast: {max(forecast_mw)} MW at MTU {forecast_mw.index(max(forecast_mw))}\")\n",
"print(f\"Min forecast: {min(forecast_mw)} MW\")\n",
"print(f\"Average: {sum(forecast_mw)/len(forecast_mw):.1f} MW\")"
"print(f\"Average: {sum(forecast_mw) / len(forecast_mw):.1f} MW\")"
]
},
{
Expand All @@ -224,10 +244,10 @@
"id": "a1b2c3d4-0001-0008-0001-000000000001",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T08:25:52.777882Z",
"iopub.status.busy": "2026-03-15T08:25:52.777795Z",
"iopub.status.idle": "2026-03-15T08:25:52.782591Z",
"shell.execute_reply": "2026-03-15T08:25:52.782358Z"
"iopub.execute_input": "2026-03-26T16:04:10.866873Z",
"iopub.status.busy": "2026-03-26T16:04:10.866826Z",
"iopub.status.idle": "2026-03-26T16:04:10.869803Z",
"shell.execute_reply": "2026-03-26T16:04:10.869611Z"
}
},
"outputs": [
Expand Down Expand Up @@ -304,10 +324,10 @@
"id": "a1b2c3d4-0001-0010-0001-000000000001",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T08:25:52.783675Z",
"iopub.status.busy": "2026-03-15T08:25:52.783604Z",
"iopub.status.idle": "2026-03-15T08:25:52.799636Z",
"shell.execute_reply": "2026-03-15T08:25:52.799418Z"
"iopub.execute_input": "2026-03-26T16:04:10.870816Z",
"iopub.status.busy": "2026-03-26T16:04:10.870759Z",
"iopub.status.idle": "2026-03-26T16:04:10.878754Z",
"shell.execute_reply": "2026-03-26T16:04:10.878544Z"
}
},
"outputs": [
Expand Down Expand Up @@ -436,14 +456,16 @@
"summaries = []\n",
"for bid in bids:\n",
" s = get_curve_summary(bid.curve)\n",
" summaries.append({\n",
" \"mtu_start\": bid.curve.mtu.start.astimezone(CET).strftime(\"%H:%M\"),\n",
" \"total_volume_mw\": float(s[\"total_volume\"]),\n",
" \"min_price\": float(s[\"min_price\"]),\n",
" \"max_price\": float(s[\"max_price\"]),\n",
" \"avg_price\": float(s[\"avg_price\"]) if s[\"avg_price\"] else None,\n",
" \"num_steps\": s[\"num_steps\"],\n",
" })\n",
" summaries.append(\n",
" {\n",
" \"mtu_start\": bid.curve.mtu.start.astimezone(CET).strftime(\"%H:%M\"),\n",
" \"total_volume_mw\": float(s[\"total_volume\"]),\n",
" \"min_price\": float(s[\"min_price\"]),\n",
" \"max_price\": float(s[\"max_price\"]),\n",
" \"avg_price\": float(s[\"avg_price\"]) if s[\"avg_price\"] else None,\n",
" \"num_steps\": s[\"num_steps\"],\n",
" }\n",
" )\n",
"\n",
"df = pd.DataFrame(summaries)\n",
"print(f\"Bid portfolio summary ({len(df)} bids):\")\n",
Expand All @@ -467,10 +489,10 @@
"id": "a1b2c3d4-0001-0012-0001-000000000001",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T08:25:52.800743Z",
"iopub.status.busy": "2026-03-15T08:25:52.800676Z",
"iopub.status.idle": "2026-03-15T08:25:53.152194Z",
"shell.execute_reply": "2026-03-15T08:25:53.151912Z"
"iopub.execute_input": "2026-03-26T16:04:10.879826Z",
"iopub.status.busy": "2026-03-26T16:04:10.879773Z",
"iopub.status.idle": "2026-03-26T16:04:11.113645Z",
"shell.execute_reply": "2026-03-26T16:04:11.113343Z"
}
},
"outputs": [
Expand Down Expand Up @@ -501,7 +523,8 @@
"fig, axes = plt.subplots(2, 2, figsize=(12, 8), sharex=False, sharey=False)\n",
"fig.suptitle(\n",
" \"Hallingdal Wind Farm — Day-Ahead Supply Curves (NO2, 2026-03-15)\",\n",
" fontsize=14, fontweight=\"bold\"\n",
" fontsize=14,\n",
" fontweight=\"bold\",\n",
")\n",
"\n",
"for ax, idx, label, color in zip(axes.flat, plot_mtu_indices, plot_labels, colors):\n",
Expand Down Expand Up @@ -532,9 +555,17 @@
"\n",
" # Annotate total volume\n",
" total = float(bid.curve.total_volume)\n",
" ax.text(0.97, 0.05, f\"Total: {total:.0f} MW\",\n",
" transform=ax.transAxes, ha=\"right\", va=\"bottom\",\n",
" fontsize=9, color=color, fontweight=\"bold\")\n",
" ax.text(\n",
" 0.97,\n",
" 0.05,\n",
" f\"Total: {total:.0f} MW\",\n",
" transform=ax.transAxes,\n",
" ha=\"right\",\n",
" va=\"bottom\",\n",
" fontsize=9,\n",
" color=color,\n",
" fontweight=\"bold\",\n",
" )\n",
"\n",
"plt.tight_layout()\n",
"plt.savefig(\"supply_curves.png\", dpi=120, bbox_inches=\"tight\")\n",
Expand All @@ -556,10 +587,10 @@
"id": "a1b2c3d4-0001-0014-0001-000000000001",
"metadata": {
"execution": {
"iopub.execute_input": "2026-03-15T08:25:53.153645Z",
"iopub.status.busy": "2026-03-15T08:25:53.153520Z",
"iopub.status.idle": "2026-03-15T08:25:53.274705Z",
"shell.execute_reply": "2026-03-15T08:25:53.274437Z"
"iopub.execute_input": "2026-03-26T16:04:11.114790Z",
"iopub.status.busy": "2026-03-26T16:04:11.114726Z",
"iopub.status.idle": "2026-03-26T16:04:11.199800Z",
"shell.execute_reply": "2026-03-26T16:04:11.199507Z"
}
},
"outputs": [
Expand All @@ -582,8 +613,10 @@
}
],
"source": [
"hours = [b.curve.mtu.start.astimezone(CET).hour + b.curve.mtu.start.astimezone(CET).minute / 60\n",
" for b in bids]\n",
"hours = [\n",
" b.curve.mtu.start.astimezone(CET).hour + b.curve.mtu.start.astimezone(CET).minute / 60\n",
" for b in bids\n",
"]\n",
"volumes = [float(b.curve.total_volume) for b in bids]\n",
"\n",
"# Volume by tier\n",
Expand All @@ -593,16 +626,20 @@
"\n",
"fig, ax = plt.subplots(figsize=(14, 5))\n",
"ax.stackplot(\n",
" hours, vol_t1, vol_t2, vol_t3,\n",
" labels=[f\"Step 1 ({float(PRICE_TIER_1):.0f} EUR/MWh)\",\n",
" f\"Step 2 ({float(PRICE_TIER_2):.0f} EUR/MWh)\",\n",
" f\"Step 3 ({float(PRICE_TIER_3):.0f} EUR/MWh)\"],\n",
" hours,\n",
" vol_t1,\n",
" vol_t2,\n",
" vol_t3,\n",
" labels=[\n",
" f\"Step 1 ({float(PRICE_TIER_1):.0f} EUR/MWh)\",\n",
" f\"Step 2 ({float(PRICE_TIER_2):.0f} EUR/MWh)\",\n",
" f\"Step 3 ({float(PRICE_TIER_3):.0f} EUR/MWh)\",\n",
" ],\n",
" colors=[\"#2ecc71\", \"#f39c12\", \"#e74c3c\"],\n",
" alpha=0.8,\n",
")\n",
"ax.set_title(\n",
" \"Hallingdal Wind Farm — Bid Volume Profile (NO2, 2026-03-15)\",\n",
" fontsize=13, fontweight=\"bold\"\n",
" \"Hallingdal Wind Farm — Bid Volume Profile (NO2, 2026-03-15)\", fontsize=13, fontweight=\"bold\"\n",
")\n",
"ax.set_xlabel(\"Hour (CET)\")\n",
"ax.set_ylabel(\"Volume (MW)\")\n",
Expand Down Expand Up @@ -651,7 +688,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.8"
"version": "3.14.3"
}
},
"nbformat": 4,
Expand Down
Loading
Loading