diff --git a/.claude/commands/checks.md b/.claude/commands/checks.md index 0c137dc..0f9f6f1 100644 --- a/.claude/commands/checks.md +++ b/.claude/commands/checks.md @@ -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 \ No newline at end of file +1. Review the README file, check nothing major is missing, suggest additions if something is identified diff --git a/README.md b/README.md index 61ba22d..a29f3ae 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/examples/01_simple_hourly_bids.ipynb b/examples/01_simple_hourly_bids.ipynb index 9537ce5..b528164 100644 --- a/examples/01_simple_hourly_bids.ipynb +++ b/examples/01_simple_hourly_bids.ipynb @@ -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": [ @@ -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": [ @@ -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": [ @@ -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", @@ -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\")" ] }, { @@ -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": [ @@ -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": [ @@ -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", @@ -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": [ @@ -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", @@ -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", @@ -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": [ @@ -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", @@ -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", @@ -651,7 +688,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.8" + "version": "3.14.3" } }, "nbformat": 4, diff --git a/examples/02_block_bids.ipynb b/examples/02_block_bids.ipynb index 4754437..c92d55f 100644 --- a/examples/02_block_bids.ipynb +++ b/examples/02_block_bids.ipynb @@ -36,10 +36,10 @@ "id": "b1b2c3d4-0002-0002-0001-000000000002", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:54.165039Z", - "iopub.status.busy": "2026-03-15T08:25:54.164904Z", - "iopub.status.idle": "2026-03-15T08:25:54.613550Z", - "shell.execute_reply": "2026-03-15T08:25:54.613298Z" + "iopub.execute_input": "2026-03-26T16:04:12.317477Z", + "iopub.status.busy": "2026-03-26T16:04:12.317373Z", + "iopub.status.idle": "2026-03-26T16:04:12.776516Z", + "shell.execute_reply": "2026-03-26T16:04:12.776281Z" } }, "outputs": [ @@ -98,10 +98,10 @@ "id": "b1b2c3d4-0002-0004-0001-000000000002", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:54.614953Z", - "iopub.status.busy": "2026-03-15T08:25:54.614813Z", - "iopub.status.idle": "2026-03-15T08:25:54.618404Z", - "shell.execute_reply": "2026-03-15T08:25:54.618114Z" + "iopub.execute_input": "2026-03-26T16:04:12.777735Z", + "iopub.status.busy": "2026-03-26T16:04:12.777631Z", + "iopub.status.idle": "2026-03-26T16:04:12.780109Z", + "shell.execute_reply": "2026-03-26T16:04:12.779885Z" } }, "outputs": [ @@ -120,6 +120,7 @@ "delivery_day = datetime(2026, 3, 15, tzinfo=CET)\n", "MTU = MTUDuration.QUARTER_HOURLY # 15-min MTUs\n", "\n", + "\n", "def make_period(hour_start: int, hour_end: int) -> DeliveryPeriod:\n", " \"\"\"Build a DeliveryPeriod from hour offsets on the delivery day.\"\"\"\n", " return DeliveryPeriod(\n", @@ -128,13 +129,20 @@ " duration=MTU,\n", " )\n", "\n", - "startup_period = make_period(6, 8) # 06:00–08:00 (8 MTUs)\n", + "\n", + "startup_period = make_period(6, 8) # 06:00–08:00 (8 MTUs)\n", "base_load_period = make_period(8, 14) # 08:00–14:00 (24 MTUs)\n", "peak_load_period = make_period(8, 12) # 08:00–12:00 (16 MTUs)\n", "\n", - "print(f\"Startup period: {startup_period.mtu_count} MTUs ({startup_period.start.astimezone(CET).strftime('%H:%M')}–{startup_period.end.astimezone(CET).strftime('%H:%M')} CET)\")\n", - "print(f\"Base-load period: {base_load_period.mtu_count} MTUs ({base_load_period.start.astimezone(CET).strftime('%H:%M')}–{base_load_period.end.astimezone(CET).strftime('%H:%M')} CET)\")\n", - "print(f\"Peak-load period: {peak_load_period.mtu_count} MTUs ({peak_load_period.start.astimezone(CET).strftime('%H:%M')}–{peak_load_period.end.astimezone(CET).strftime('%H:%M')} CET)\")" + "print(\n", + " f\"Startup period: {startup_period.mtu_count} MTUs ({startup_period.start.astimezone(CET).strftime('%H:%M')}–{startup_period.end.astimezone(CET).strftime('%H:%M')} CET)\"\n", + ")\n", + "print(\n", + " f\"Base-load period: {base_load_period.mtu_count} MTUs ({base_load_period.start.astimezone(CET).strftime('%H:%M')}–{base_load_period.end.astimezone(CET).strftime('%H:%M')} CET)\"\n", + ")\n", + "print(\n", + " f\"Peak-load period: {peak_load_period.mtu_count} MTUs ({peak_load_period.start.astimezone(CET).strftime('%H:%M')}–{peak_load_period.end.astimezone(CET).strftime('%H:%M')} CET)\"\n", + ")" ] }, { @@ -155,10 +163,10 @@ "id": "b1b2c3d4-0002-0006-0001-000000000002", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:54.619563Z", - "iopub.status.busy": "2026-03-15T08:25:54.619490Z", - "iopub.status.idle": "2026-03-15T08:25:54.621847Z", - "shell.execute_reply": "2026-03-15T08:25:54.621562Z" + "iopub.execute_input": "2026-03-26T16:04:12.781068Z", + "iopub.status.busy": "2026-03-26T16:04:12.781013Z", + "iopub.status.idle": "2026-03-26T16:04:12.782832Z", + "shell.execute_reply": "2026-03-26T16:04:12.782658Z" } }, "outputs": [ @@ -181,8 +189,8 @@ " bidding_zone=BiddingZone.DE_LU,\n", " direction=Direction.SELL,\n", " delivery_period=base_load_period,\n", - " price=Decimal(\"65.00\"), # EUR/MWh minimum acceptable price\n", - " volume=Decimal(\"350\"), # MW per MTU\n", + " price=Decimal(\"65.00\"), # EUR/MWh minimum acceptable price\n", + " volume=Decimal(\"350\"), # MW per MTU\n", " bid_id=\"borgholt-main-block\",\n", " metadata={\"asset\": \"borgholt-ccgt\", \"bid_strategy\": \"base-load\"},\n", ")\n", @@ -214,10 +222,10 @@ "id": "b1b2c3d4-0002-0008-0001-000000000002", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:54.623005Z", - "iopub.status.busy": "2026-03-15T08:25:54.622922Z", - "iopub.status.idle": "2026-03-15T08:25:54.625780Z", - "shell.execute_reply": "2026-03-15T08:25:54.625563Z" + "iopub.execute_input": "2026-03-26T16:04:12.783792Z", + "iopub.status.busy": "2026-03-26T16:04:12.783739Z", + "iopub.status.idle": "2026-03-26T16:04:12.785962Z", + "shell.execute_reply": "2026-03-26T16:04:12.785782Z" } }, "outputs": [ @@ -294,10 +302,10 @@ "id": "b1b2c3d4-0002-0010-0001-000000000002", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:54.626954Z", - "iopub.status.busy": "2026-03-15T08:25:54.626834Z", - "iopub.status.idle": "2026-03-15T08:25:54.629857Z", - "shell.execute_reply": "2026-03-15T08:25:54.629526Z" + "iopub.execute_input": "2026-03-26T16:04:12.786995Z", + "iopub.status.busy": "2026-03-26T16:04:12.786929Z", + "iopub.status.idle": "2026-03-26T16:04:12.789192Z", + "shell.execute_reply": "2026-03-26T16:04:12.788992Z" } }, "outputs": [ @@ -365,10 +373,10 @@ "id": "b1b2c3d4-0002-0012-0001-000000000002", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:54.631102Z", - "iopub.status.busy": "2026-03-15T08:25:54.631015Z", - "iopub.status.idle": "2026-03-15T08:25:54.786534Z", - "shell.execute_reply": "2026-03-15T08:25:54.786104Z" + "iopub.execute_input": "2026-03-26T16:04:12.790188Z", + "iopub.status.busy": "2026-03-26T16:04:12.790135Z", + "iopub.status.idle": "2026-03-26T16:04:12.892959Z", + "shell.execute_reply": "2026-03-26T16:04:12.892723Z" } }, "outputs": [ @@ -393,25 +401,46 @@ "source": [ "fig, ax = plt.subplots(figsize=(13, 6))\n", "fig.suptitle(\n", - " \"Borgholt CCGT — Block Bid Timeline (DE-LU, 2026-03-15)\",\n", - " fontsize=13, fontweight=\"bold\"\n", + " \"Borgholt CCGT — Block Bid Timeline (DE-LU, 2026-03-15)\", fontsize=13, fontweight=\"bold\"\n", ")\n", "\n", + "\n", "def hour_offset(dt: datetime) -> float:\n", " \"\"\"Convert a datetime to fractional hours from midnight CET.\"\"\"\n", " local = dt.astimezone(CET)\n", " return local.hour + local.minute / 60\n", "\n", + "\n", "# Define bids to plot: (label, delivery_period, color, y_pos, annotation)\n", "bid_bars = [\n", - " (\"Startup\\n(Linked)\", startup_linked.delivery_period, \"#e67e22\", 3,\n", - " f\"{startup_linked.price:.2f} EUR/MWh\\n{startup_linked.volume} MW\"),\n", - " (\"Main Block\\n(Parent)\", main_block.delivery_period, \"#2980b9\", 2,\n", - " f\"{main_block.price} EUR/MWh\\n{main_block.volume} MW\"),\n", - " (\"Base-load\\n(Exclusive)\", base_load_option.delivery_period, \"#27ae60\", 1,\n", - " f\"{base_load_option.price} EUR/MWh\\n{base_load_option.volume} MW\"),\n", - " (\"Peak-load\\n(Exclusive)\", peak_load_option.delivery_period, \"#c0392b\", 0,\n", - " f\"{peak_load_option.price} EUR/MWh\\n{peak_load_option.volume} MW\"),\n", + " (\n", + " \"Startup\\n(Linked)\",\n", + " startup_linked.delivery_period,\n", + " \"#e67e22\",\n", + " 3,\n", + " f\"{startup_linked.price:.2f} EUR/MWh\\n{startup_linked.volume} MW\",\n", + " ),\n", + " (\n", + " \"Main Block\\n(Parent)\",\n", + " main_block.delivery_period,\n", + " \"#2980b9\",\n", + " 2,\n", + " f\"{main_block.price} EUR/MWh\\n{main_block.volume} MW\",\n", + " ),\n", + " (\n", + " \"Base-load\\n(Exclusive)\",\n", + " base_load_option.delivery_period,\n", + " \"#27ae60\",\n", + " 1,\n", + " f\"{base_load_option.price} EUR/MWh\\n{base_load_option.volume} MW\",\n", + " ),\n", + " (\n", + " \"Peak-load\\n(Exclusive)\",\n", + " peak_load_option.delivery_period,\n", + " \"#c0392b\",\n", + " 0,\n", + " f\"{peak_load_option.price} EUR/MWh\\n{peak_load_option.volume} MW\",\n", + " ),\n", "]\n", "\n", "bar_height = 0.5\n", @@ -419,30 +448,54 @@ " x_start = hour_offset(period.start)\n", " x_end = hour_offset(period.end)\n", " width = x_end - x_start\n", - " ax.barh(y, width, left=x_start, height=bar_height,\n", - " color=color, alpha=0.85, edgecolor=\"white\", linewidth=1.5)\n", - " ax.text(x_start + width / 2, y, annotation,\n", - " ha=\"center\", va=\"center\", fontsize=8, color=\"white\", fontweight=\"bold\")\n", + " ax.barh(\n", + " y,\n", + " width,\n", + " left=x_start,\n", + " height=bar_height,\n", + " color=color,\n", + " alpha=0.85,\n", + " edgecolor=\"white\",\n", + " linewidth=1.5,\n", + " )\n", + " ax.text(\n", + " x_start + width / 2,\n", + " y,\n", + " annotation,\n", + " ha=\"center\",\n", + " va=\"center\",\n", + " fontsize=8,\n", + " color=\"white\",\n", + " fontweight=\"bold\",\n", + " )\n", " ax.text(x_start - 0.1, y, label, ha=\"right\", va=\"center\", fontsize=9)\n", "\n", "# Draw dependency arrow: startup → main block\n", "ax.annotate(\n", - " \"\", xy=(hour_offset(main_block.delivery_period.start), 2.25),\n", + " \"\",\n", + " xy=(hour_offset(main_block.delivery_period.start), 2.25),\n", " xytext=(hour_offset(startup_linked.delivery_period.end), 2.75),\n", " arrowprops=dict(arrowstyle=\"->\", color=\"black\", lw=1.5),\n", ")\n", "ax.text(\n", - " hour_offset(startup_linked.delivery_period.end) + 0.05, 2.5,\n", - " \"depends on\", fontsize=8, va=\"center\", color=\"black\", style=\"italic\"\n", + " hour_offset(startup_linked.delivery_period.end) + 0.05,\n", + " 2.5,\n", + " \"depends on\",\n", + " fontsize=8,\n", + " va=\"center\",\n", + " color=\"black\",\n", + " style=\"italic\",\n", ")\n", "\n", "# Exclusive group brace\n", "ax.annotate(\n", " \"Exclusive Group\",\n", - " xy=(8.0, 0.5), xytext=(5.5, 0.5),\n", - " fontsize=9, color=\"#7f8c8d\", style=\"italic\",\n", - " arrowprops=dict(arrowstyle=\"|-|\", color=\"#7f8c8d\", lw=1.5,\n", - " connectionstyle=\"arc3,rad=0\"),\n", + " xy=(8.0, 0.5),\n", + " xytext=(5.5, 0.5),\n", + " fontsize=9,\n", + " color=\"#7f8c8d\",\n", + " style=\"italic\",\n", + " arrowprops=dict(arrowstyle=\"|-|\", color=\"#7f8c8d\", lw=1.5, connectionstyle=\"arc3,rad=0\"),\n", ")\n", "\n", "# Formatting\n", @@ -479,10 +532,10 @@ "id": "b1b2c3d4-0002-0014-0001-000000000002", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:54.787952Z", - "iopub.status.busy": "2026-03-15T08:25:54.787836Z", - "iopub.status.idle": "2026-03-15T08:25:54.790694Z", - "shell.execute_reply": "2026-03-15T08:25:54.790456Z" + "iopub.execute_input": "2026-03-26T16:04:12.894004Z", + "iopub.status.busy": "2026-03-26T16:04:12.893941Z", + "iopub.status.idle": "2026-03-26T16:04:12.896348Z", + "shell.execute_reply": "2026-03-26T16:04:12.896108Z" } }, "outputs": [ @@ -529,7 +582,9 @@ " if base_rev == 0 and peak_rev == 0:\n", " winner = \"NONE\"\n", "\n", - " print(f\" {cp:>12} EUR/MWh {float(base_rev):>15,.0f} EUR {float(peak_rev):>12,.0f} EUR {winner:>10}\")" + " print(\n", + " f\" {cp:>12} EUR/MWh {float(base_rev):>15,.0f} EUR {float(peak_rev):>12,.0f} EUR {winner:>10}\"\n", + " )" ] }, { @@ -570,7 +625,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.8" + "version": "3.14.3" } }, "nbformat": 4, diff --git a/examples/03_merit_order_curves.ipynb b/examples/03_merit_order_curves.ipynb index edd6bb6..bcdd504 100644 --- a/examples/03_merit_order_curves.ipynb +++ b/examples/03_merit_order_curves.ipynb @@ -42,10 +42,10 @@ "id": "c1b2c3d4-0003-0002-0001-000000000003", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:55.880971Z", - "iopub.status.busy": "2026-03-15T08:25:55.880844Z", - "iopub.status.idle": "2026-03-15T08:25:56.323856Z", - "shell.execute_reply": "2026-03-15T08:25:56.323571Z" + "iopub.execute_input": "2026-03-26T16:04:13.812861Z", + "iopub.status.busy": "2026-03-26T16:04:13.812607Z", + "iopub.status.idle": "2026-03-26T16:04:14.297838Z", + "shell.execute_reply": "2026-03-26T16:04:14.297619Z" } }, "outputs": [ @@ -103,10 +103,10 @@ "id": "c1b2c3d4-0003-0004-0001-000000000003", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:56.325900Z", - "iopub.status.busy": "2026-03-15T08:25:56.325681Z", - "iopub.status.idle": "2026-03-15T08:25:56.327978Z", - "shell.execute_reply": "2026-03-15T08:25:56.327687Z" + "iopub.execute_input": "2026-03-26T16:04:14.299020Z", + "iopub.status.busy": "2026-03-26T16:04:14.298913Z", + "iopub.status.idle": "2026-03-26T16:04:14.300796Z", + "shell.execute_reply": "2026-03-26T16:04:14.300591Z" } }, "outputs": [ @@ -153,10 +153,10 @@ "id": "c1b2c3d4-0003-0006-0001-000000000003", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:56.329296Z", - "iopub.status.busy": "2026-03-15T08:25:56.329198Z", - "iopub.status.idle": "2026-03-15T08:25:56.332816Z", - "shell.execute_reply": "2026-03-15T08:25:56.332512Z" + "iopub.execute_input": "2026-03-26T16:04:14.301761Z", + "iopub.status.busy": "2026-03-26T16:04:14.301700Z", + "iopub.status.idle": "2026-03-26T16:04:14.304467Z", + "shell.execute_reply": "2026-03-26T16:04:14.304262Z" } }, "outputs": [ @@ -181,49 +181,66 @@ " mtu=mtu,\n", " )\n", "\n", + "\n", "# Hallingdal Wind: 120 MW capacity, morning wind at ~80 MW\n", - "wind_curve = supply_curve([\n", - " (5.0, 32.0), # firm 40%: almost free (renewable incentive)\n", - " (20.0, 32.0), # mid 40%: low cost\n", - " (45.0, 16.0), # top 20%: forecast uncertainty premium\n", - "], ref_mtu)\n", + "wind_curve = supply_curve(\n", + " [\n", + " (5.0, 32.0), # firm 40%: almost free (renewable incentive)\n", + " (20.0, 32.0), # mid 40%: low cost\n", + " (45.0, 16.0), # top 20%: forecast uncertainty premium\n", + " ],\n", + " ref_mtu,\n", + ")\n", "\n", "# Voss Run-of-River Hydro: 200 MW, near-full output in spring\n", - "ror_hydro_curve = supply_curve([\n", - " (2.0, 80.0), # base flow: nearly zero marginal cost\n", - " (8.0, 80.0), # higher flow band\n", - " (15.0, 40.0), # regulated flow (slightly restricted)\n", - "], ref_mtu)\n", + "ror_hydro_curve = supply_curve(\n", + " [\n", + " (2.0, 80.0), # base flow: nearly zero marginal cost\n", + " (8.0, 80.0), # higher flow band\n", + " (15.0, 40.0), # regulated flow (slightly restricted)\n", + " ],\n", + " ref_mtu,\n", + ")\n", "\n", "# Rjukan Reservoir Hydro: 300 MW, flexible with opportunity cost\n", - "reservoir_hydro_curve = supply_curve([\n", - " (25.0, 60.0), # base dispatch: water already scheduled\n", - " (40.0, 120.0), # mid dispatch: moderate opportunity cost\n", - " (60.0, 80.0), # peak dispatch: high opportunity cost (saving for evening)\n", - " (80.0, 40.0), # emergency dispatch: very high opportunity cost\n", - "], ref_mtu)\n", + "reservoir_hydro_curve = supply_curve(\n", + " [\n", + " (25.0, 60.0), # base dispatch: water already scheduled\n", + " (40.0, 120.0), # mid dispatch: moderate opportunity cost\n", + " (60.0, 80.0), # peak dispatch: high opportunity cost (saving for evening)\n", + " (80.0, 40.0), # emergency dispatch: very high opportunity cost\n", + " ],\n", + " ref_mtu,\n", + ")\n", "\n", "# Sola Gas Turbine: 150 MW, high marginal cost (fuel + CO2)\n", "# Gas price EUR 35/MWh, efficiency 38%, CO2 EUR 75/tonne at 0.18 tCO2/MWh\n", - "gas_fuel_cost = 35.0 / 0.38 # ~92 EUR/MWh\n", - "gas_co2_cost = 75.0 * 0.18 # ~13.5 EUR/MWh\n", - "gas_vom = 4.0 # variable O&M\n", + "gas_fuel_cost = 35.0 / 0.38 # ~92 EUR/MWh\n", + "gas_co2_cost = 75.0 * 0.18 # ~13.5 EUR/MWh\n", + "gas_vom = 4.0 # variable O&M\n", "gas_marginal = gas_fuel_cost + gas_co2_cost + gas_vom # ~109.5 EUR/MWh\n", "\n", - "gas_curve = supply_curve([\n", - " (gas_marginal, 100.0), # base dispatch\n", - " (gas_marginal + 10.0, 30.0), # contingency (less efficient)\n", - " (gas_marginal + 25.0, 20.0), # emergency capacity\n", - "], ref_mtu)\n", + "gas_curve = supply_curve(\n", + " [\n", + " (gas_marginal, 100.0), # base dispatch\n", + " (gas_marginal + 10.0, 30.0), # contingency (less efficient)\n", + " (gas_marginal + 25.0, 20.0), # emergency capacity\n", + " ],\n", + " ref_mtu,\n", + ")\n", "\n", "print(\"Asset curves for MTU 09:15–09:30 CET:\")\n", "for name, curve in [\n", - " (\"Wind\", wind_curve), (\"RoR Hydro\", ror_hydro_curve),\n", - " (\"Reservoir Hydro\", reservoir_hydro_curve), (\"Gas\", gas_curve)\n", + " (\"Wind\", wind_curve),\n", + " (\"RoR Hydro\", ror_hydro_curve),\n", + " (\"Reservoir Hydro\", reservoir_hydro_curve),\n", + " (\"Gas\", gas_curve),\n", "]:\n", " s = get_curve_summary(curve)\n", - " print(f\" {name:<20}: {float(s['total_volume']):6.0f} MW, \"\n", - " f\"price range {float(s['min_price']):.1f}–{float(s['max_price']):.1f} EUR/MWh\")" + " print(\n", + " f\" {name:<20}: {float(s['total_volume']):6.0f} MW, \"\n", + " f\"price range {float(s['min_price']):.1f}–{float(s['max_price']):.1f} EUR/MWh\"\n", + " )" ] }, { @@ -243,10 +260,10 @@ "id": "c1b2c3d4-0003-0008-0001-000000000003", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:56.334346Z", - "iopub.status.busy": "2026-03-15T08:25:56.334266Z", - "iopub.status.idle": "2026-03-15T08:25:56.339809Z", - "shell.execute_reply": "2026-03-15T08:25:56.339563Z" + "iopub.execute_input": "2026-03-26T16:04:14.305390Z", + "iopub.status.busy": "2026-03-26T16:04:14.305335Z", + "iopub.status.idle": "2026-03-26T16:04:14.309101Z", + "shell.execute_reply": "2026-03-26T16:04:14.308891Z" } }, "outputs": [ @@ -308,10 +325,10 @@ "id": "c1b2c3d4-0003-0010-0001-000000000003", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:56.340976Z", - "iopub.status.busy": "2026-03-15T08:25:56.340903Z", - "iopub.status.idle": "2026-03-15T08:25:56.615992Z", - "shell.execute_reply": "2026-03-15T08:25:56.615565Z" + "iopub.execute_input": "2026-03-26T16:04:14.310053Z", + "iopub.status.busy": "2026-03-26T16:04:14.310001Z", + "iopub.status.idle": "2026-03-26T16:04:14.485476Z", + "shell.execute_reply": "2026-03-26T16:04:14.485245Z" } }, "outputs": [ @@ -343,10 +360,12 @@ " x.extend([x[-1], x[-1] + float(step.volume)])\n", " return x[:-1], y_steps\n", "\n", + "\n", "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))\n", "fig.suptitle(\n", " \"Fjord Energy Portfolio — Merit Order Curves (09:15–09:30 CET, 2026-03-15)\",\n", - " fontsize=13, fontweight=\"bold\"\n", + " fontsize=13,\n", + " fontweight=\"bold\",\n", ")\n", "\n", "# ---- Left: individual assets ----\n", @@ -376,16 +395,20 @@ "\n", "# Mark a hypothetical market clearing price\n", "CLEARING_PRICE = 50.0\n", - "ax2.axhline(CLEARING_PRICE, color=\"#e74c3c\", linestyle=\"--\", linewidth=1.5,\n", - " label=f\"Clearing price: {CLEARING_PRICE} EUR/MWh\")\n", + "ax2.axhline(\n", + " CLEARING_PRICE,\n", + " color=\"#e74c3c\",\n", + " linestyle=\"--\",\n", + " linewidth=1.5,\n", + " label=f\"Clearing price: {CLEARING_PRICE} EUR/MWh\",\n", + ")\n", "\n", "# Find cleared volume at clearing price\n", "cleared_vol = sum(\n", " float(s.volume) for s in portfolio_curve.steps if float(s.price) <= CLEARING_PRICE\n", ")\n", "ax2.axvline(cleared_vol, color=\"#e74c3c\", linestyle=\":\", alpha=0.7)\n", - "ax2.text(cleared_vol + 5, 2, f\"{cleared_vol:.0f} MW cleared\",\n", - " color=\"#e74c3c\", fontsize=9)\n", + "ax2.text(cleared_vol + 5, 2, f\"{cleared_vol:.0f} MW cleared\", color=\"#e74c3c\", fontsize=9)\n", "\n", "ax2.set_title(\"Aggregate Portfolio Curve\", fontsize=11)\n", "ax2.set_xlabel(\"Cumulative Volume (MW)\")\n", @@ -417,10 +440,10 @@ "id": "c1b2c3d4-0003-0012-0001-000000000003", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:56.617439Z", - "iopub.status.busy": "2026-03-15T08:25:56.617337Z", - "iopub.status.idle": "2026-03-15T08:25:56.628371Z", - "shell.execute_reply": "2026-03-15T08:25:56.627969Z" + "iopub.execute_input": "2026-03-26T16:04:14.486586Z", + "iopub.status.busy": "2026-03-26T16:04:14.486494Z", + "iopub.status.idle": "2026-03-26T16:04:14.494114Z", + "shell.execute_reply": "2026-03-26T16:04:14.493896Z" } }, "outputs": [ @@ -441,15 +464,39 @@ " evening_peak = 2.5 * math.exp(-((hour - 18) ** 2) / 4)\n", " return base + morning_peak + evening_peak\n", "\n", + "\n", "# Wind forecast across the day\n", - "hourly_wind = [55, 58, 62, 70, 75, 78, 80, 82, 85, 88, 90, 88,\n", - " 85, 80, 75, 70, 68, 65, 60, 55, 52, 50, 48, 50]\n", + "hourly_wind = [\n", + " 55,\n", + " 58,\n", + " 62,\n", + " 70,\n", + " 75,\n", + " 78,\n", + " 80,\n", + " 82,\n", + " 85,\n", + " 88,\n", + " 90,\n", + " 88,\n", + " 85,\n", + " 80,\n", + " 75,\n", + " 70,\n", + " 68,\n", + " 65,\n", + " 60,\n", + " 55,\n", + " 52,\n", + " 50,\n", + " 48,\n", + " 50,\n", + "]\n", "wind_forecast = [mw for mw in hourly_wind for _ in range(4)]\n", "\n", "# Build all 96 MTU intervals\n", "mtu_list = [\n", - " MTUInterval.from_start(delivery_day + timedelta(minutes=15 * i), MTU)\n", - " for i in range(96)\n", + " MTUInterval.from_start(delivery_day + timedelta(minutes=15 * i), MTU) for i in range(96)\n", "]\n", "\n", "vols_wind, vols_ror, vols_res, vols_gas, marginal_prices = [], [], [], [], []\n", @@ -476,7 +523,7 @@ " marginal_prices.append(float(portfolio.max_price))\n", "\n", "print(f\"Built {len(mtu_list)} portfolio curves\")\n", - "peak_total = max(vols_wind[i]+vols_ror[i]+vols_res[i]+vols_gas[i] for i in range(96))\n", + "peak_total = max(vols_wind[i] + vols_ror[i] + vols_res[i] + vols_gas[i] for i in range(96))\n", "print(f\"Peak portfolio volume: {peak_total:.0f} MW\")" ] }, @@ -486,10 +533,10 @@ "id": "c1b2c3d4-0003-0013-0001-000000000003", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:56.630125Z", - "iopub.status.busy": "2026-03-15T08:25:56.629878Z", - "iopub.status.idle": "2026-03-15T08:25:56.856397Z", - "shell.execute_reply": "2026-03-15T08:25:56.856112Z" + "iopub.execute_input": "2026-03-26T16:04:14.495139Z", + "iopub.status.busy": "2026-03-26T16:04:14.495086Z", + "iopub.status.idle": "2026-03-26T16:04:14.650223Z", + "shell.execute_reply": "2026-03-26T16:04:14.649957Z" } }, "outputs": [ @@ -516,12 +563,15 @@ "\n", "fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 9), sharex=True)\n", "fig.suptitle(\n", - " \"Fjord Energy Portfolio — Intraday Supply Profile (2026-03-15)\",\n", - " fontsize=13, fontweight=\"bold\"\n", + " \"Fjord Energy Portfolio — Intraday Supply Profile (2026-03-15)\", fontsize=13, fontweight=\"bold\"\n", ")\n", "\n", "ax1.stackplot(\n", - " hours, vols_wind, vols_ror, vols_res, vols_gas,\n", + " hours,\n", + " vols_wind,\n", + " vols_ror,\n", + " vols_res,\n", + " vols_gas,\n", " labels=[\"Wind (NO2)\", \"RoR Hydro (NO5)\", \"Reservoir (NO1)\", \"Gas OCGT (NO2)\"],\n", " colors=[\"#27ae60\", \"#2980b9\", \"#8e44ad\", \"#e74c3c\"],\n", " alpha=0.82,\n", @@ -562,10 +612,10 @@ "id": "c1b2c3d4-0003-0015-0001-000000000003", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:56.857672Z", - "iopub.status.busy": "2026-03-15T08:25:56.857574Z", - "iopub.status.idle": "2026-03-15T08:25:56.957764Z", - "shell.execute_reply": "2026-03-15T08:25:56.957309Z" + "iopub.execute_input": "2026-03-26T16:04:14.651359Z", + "iopub.status.busy": "2026-03-26T16:04:14.651294Z", + "iopub.status.idle": "2026-03-26T16:04:14.721703Z", + "shell.execute_reply": "2026-03-26T16:04:14.721477Z" } }, "outputs": [ @@ -603,11 +653,14 @@ "source": [ "# Scenario 1: Gas price shock — rebuild gas curve at +EUR 15/MWh higher price\n", "gas_marginal_shocked = gas_marginal + 15.0\n", - "gas_curve_shocked = supply_curve([\n", - " (gas_marginal_shocked, 100.0),\n", - " (gas_marginal_shocked + 10.0, 30.0),\n", - " (gas_marginal_shocked + 25.0, 20.0),\n", - "], ref_mtu)\n", + "gas_curve_shocked = supply_curve(\n", + " [\n", + " (gas_marginal_shocked, 100.0),\n", + " (gas_marginal_shocked + 10.0, 30.0),\n", + " (gas_marginal_shocked + 25.0, 20.0),\n", + " ],\n", + " ref_mtu,\n", + ")\n", "\n", "# Scenario 2: Wind curtailment — scale volumes down by 20%\n", "wind_curtailed = scale_curve(wind_curve, Decimal(\"0.8\"))\n", @@ -616,15 +669,21 @@ "portfolio_capped = clip_curve(portfolio_curve, max_price=Decimal(\"100\"))\n", "\n", "print(\"Transformation results:\")\n", - "print(f\" Gas curve (base): {float(gas_curve.min_price):.1f}–{float(gas_curve.max_price):.1f} EUR/MWh\")\n", - "print(f\" Gas curve (+15 shock): {float(gas_curve_shocked.min_price):.1f}–{float(gas_curve_shocked.max_price):.1f} EUR/MWh\")\n", + "print(\n", + " f\" Gas curve (base): {float(gas_curve.min_price):.1f}–{float(gas_curve.max_price):.1f} EUR/MWh\"\n", + ")\n", + "print(\n", + " f\" Gas curve (+15 shock): {float(gas_curve_shocked.min_price):.1f}–{float(gas_curve_shocked.max_price):.1f} EUR/MWh\"\n", + ")\n", "print(f\" Wind original volume: {float(wind_curve.total_volume):.0f} MW\")\n", "print(f\" Wind curtailed (80%): {float(wind_curtailed.total_volume):.0f} MW\")\n", "print(f\" Portfolio (all steps): {float(portfolio_curve.total_volume):.0f} MW\")\n", "print(f\" Portfolio (≤100 EUR): {float(portfolio_capped.total_volume):.0f} MW\")\n", "\n", "# Show impact on portfolio aggregate under gas shock\n", - "portfolio_shocked = merge_curves([wind_curve, ror_hydro_curve, reservoir_hydro_curve, gas_curve_shocked])\n", + "portfolio_shocked = merge_curves(\n", + " [wind_curve, ror_hydro_curve, reservoir_hydro_curve, gas_curve_shocked]\n", + ")\n", "\n", "fig, ax = plt.subplots(figsize=(10, 5))\n", "fig.suptitle(\"Portfolio Curve — Base vs Gas Price Shock (+€15/MWh)\", fontsize=12, fontweight=\"bold\")\n", @@ -634,8 +693,15 @@ "\n", "ax.step(x_base, y_base, where=\"post\", color=\"#2980b9\", linewidth=2, label=\"Base case\")\n", "ax.fill_between(x_base, y_base, alpha=0.12, color=\"#2980b9\", step=\"post\")\n", - "ax.step(x_shock, y_shock, where=\"post\", color=\"#e74c3c\", linewidth=2,\n", - " linestyle=\"--\", label=\"Gas shock (+€15/MWh)\")\n", + "ax.step(\n", + " x_shock,\n", + " y_shock,\n", + " where=\"post\",\n", + " color=\"#e74c3c\",\n", + " linewidth=2,\n", + " linestyle=\"--\",\n", + " label=\"Gas shock (+€15/MWh)\",\n", + ")\n", "\n", "ax.set_xlabel(\"Cumulative Volume (MW)\")\n", "ax.set_ylabel(\"Price (EUR/MWh)\")\n", @@ -686,7 +752,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.8" + "version": "3.14.3" } }, "nbformat": 4, diff --git a/examples/04_order_book_and_validation.ipynb b/examples/04_order_book_and_validation.ipynb index f2fcff5..a89b921 100644 --- a/examples/04_order_book_and_validation.ipynb +++ b/examples/04_order_book_and_validation.ipynb @@ -39,10 +39,10 @@ "id": "d1b2c3d4-0004-0002-0001-000000000004", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:58.067315Z", - "iopub.status.busy": "2026-03-15T08:25:58.067115Z", - "iopub.status.idle": "2026-03-15T08:25:58.526285Z", - "shell.execute_reply": "2026-03-15T08:25:58.525998Z" + "iopub.execute_input": "2026-03-26T16:04:15.819202Z", + "iopub.status.busy": "2026-03-26T16:04:15.819062Z", + "iopub.status.idle": "2026-03-26T16:04:16.288196Z", + "shell.execute_reply": "2026-03-26T16:04:16.287950Z" } }, "outputs": [ @@ -72,8 +72,10 @@ " BlockBid,\n", " LinkedBlockBid,\n", ")\n", + "\n", "# Curves\n", "from nexa_bidkit.curves import from_dict_list, to_dataframe as curves_to_df\n", + "\n", "# Order book management\n", "from nexa_bidkit.orders import (\n", " create_order_book,\n", @@ -87,6 +89,7 @@ " update_all_statuses,\n", " to_dataframe as orders_to_df,\n", ")\n", + "\n", "# Validation\n", "from nexa_bidkit.validation import (\n", " validate_order_book_for_submission,\n", @@ -98,6 +101,7 @@ " DataQualityError,\n", " ValidationError,\n", ")\n", + "\n", "# Types\n", "from nexa_bidkit.types import (\n", " BiddingZone,\n", @@ -111,6 +115,7 @@ " PriceQuantityCurve,\n", " PriceQuantityStep,\n", ")\n", + "\n", "# Nord Pool export\n", "from nexa_bidkit.nordpool import order_book_to_nord_pool\n", "\n", @@ -132,10 +137,10 @@ "id": "d1b2c3d4-0004-0004-0001-000000000004", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:58.527614Z", - "iopub.status.busy": "2026-03-15T08:25:58.527502Z", - "iopub.status.idle": "2026-03-15T08:25:58.530390Z", - "shell.execute_reply": "2026-03-15T08:25:58.530148Z" + "iopub.execute_input": "2026-03-26T16:04:16.289369Z", + "iopub.status.busy": "2026-03-26T16:04:16.289267Z", + "iopub.status.idle": "2026-03-26T16:04:16.291628Z", + "shell.execute_reply": "2026-03-26T16:04:16.291420Z" } }, "outputs": [ @@ -152,6 +157,7 @@ "delivery_day = datetime(2026, 3, 15, tzinfo=CET)\n", "MTU = MTUDuration.QUARTER_HOURLY\n", "\n", + "\n", "def mtu_at(hour: int, minute: int = 0) -> MTUInterval:\n", " \"\"\"Create a single 15-min MTU at the given hour:minute on the delivery day.\"\"\"\n", " return MTUInterval.from_start(\n", @@ -159,6 +165,7 @@ " duration=MTU,\n", " )\n", "\n", + "\n", "def period(h_start: int, h_end: int) -> DeliveryPeriod:\n", " \"\"\"Create a DeliveryPeriod spanning whole hours on the delivery day.\"\"\"\n", " return DeliveryPeriod(\n", @@ -167,6 +174,7 @@ " duration=MTU,\n", " )\n", "\n", + "\n", "def supply_curve(steps_data: list[tuple[float, float]], mtu: MTUInterval) -> PriceQuantityCurve:\n", " \"\"\"Build a supply curve from (price_eur, volume_mw) tuples.\"\"\"\n", " return from_dict_list(\n", @@ -175,6 +183,7 @@ " mtu=mtu,\n", " )\n", "\n", + "\n", "print(f\"Delivery day: {delivery_day.strftime('%Y-%m-%d')} (CET)\")\n", "print(f\"MTU duration: {MTU.value} ({MTU.per_day} MTUs/day)\")" ] @@ -196,10 +205,10 @@ "id": "d1b2c3d4-0004-0006-0001-000000000004", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:58.531562Z", - "iopub.status.busy": "2026-03-15T08:25:58.531491Z", - "iopub.status.idle": "2026-03-15T08:25:58.536776Z", - "shell.execute_reply": "2026-03-15T08:25:58.536461Z" + "iopub.execute_input": "2026-03-26T16:04:16.292608Z", + "iopub.status.busy": "2026-03-26T16:04:16.292557Z", + "iopub.status.idle": "2026-03-26T16:04:16.296298Z", + "shell.execute_reply": "2026-03-26T16:04:16.296102Z" } }, "outputs": [ @@ -224,17 +233,22 @@ " # Use 4 MTUs per hour; just submit the first MTU as a representative example\n", " for m in range(4):\n", " mtu = mtu_at(hour, m * 15)\n", - " curve = supply_curve([\n", - " (5.0, mw * 0.4),\n", - " (22.0, mw * 0.4),\n", - " (48.0, mw * 0.2),\n", - " ], mtu)\n", - " wind_bids.append(simple_bid_from_curve(\n", - " curve=curve,\n", - " bidding_zone=BiddingZone.NO2,\n", - " bid_id=f\"wind-h{hour:02d}m{m:02d}\",\n", - " metadata={\"asset\": \"solberg-wind\"},\n", - " ))\n", + " curve = supply_curve(\n", + " [\n", + " (5.0, mw * 0.4),\n", + " (22.0, mw * 0.4),\n", + " (48.0, mw * 0.2),\n", + " ],\n", + " mtu,\n", + " )\n", + " wind_bids.append(\n", + " simple_bid_from_curve(\n", + " curve=curve,\n", + " bidding_zone=BiddingZone.NO2,\n", + " bid_id=f\"wind-h{hour:02d}m{m:02d}\",\n", + " metadata={\"asset\": \"solberg-wind\"},\n", + " )\n", + " )\n", "\n", "print(f\"Wind bids created: {len(wind_bids)}\")\n", "\n", @@ -245,12 +259,14 @@ " for m in range(4):\n", " mtu = mtu_at(hour, m * 15)\n", " curve = supply_curve([(3.0, 80.0), (10.0, 60.0), (18.0, 40.0)], mtu)\n", - " ror_bids.append(simple_bid_from_curve(\n", - " curve=curve,\n", - " bidding_zone=BiddingZone.NO5,\n", - " bid_id=f\"ror-h{hour:02d}m{m:02d}\",\n", - " metadata={\"asset\": \"voss-ror\"},\n", - " ))\n", + " ror_bids.append(\n", + " simple_bid_from_curve(\n", + " curve=curve,\n", + " bidding_zone=BiddingZone.NO5,\n", + " bid_id=f\"ror-h{hour:02d}m{m:02d}\",\n", + " metadata={\"asset\": \"voss-ror\"},\n", + " )\n", + " )\n", "\n", "print(f\"RoR hydro bids created: {len(ror_bids)}\")\n", "\n", @@ -321,10 +337,10 @@ "id": "d1b2c3d4-0004-0008-0001-000000000004", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:58.538028Z", - "iopub.status.busy": "2026-03-15T08:25:58.537932Z", - "iopub.status.idle": "2026-03-15T08:25:58.540622Z", - "shell.execute_reply": "2026-03-15T08:25:58.540392Z" + "iopub.execute_input": "2026-03-26T16:04:16.297303Z", + "iopub.status.busy": "2026-03-26T16:04:16.297248Z", + "iopub.status.idle": "2026-03-26T16:04:16.299405Z", + "shell.execute_reply": "2026-03-26T16:04:16.299171Z" } }, "outputs": [ @@ -333,7 +349,7 @@ "output_type": "stream", "text": [ "Order book summary:\n", - " ID: book_35ae8fc2-5d64-446c-a11c-5f8bd47620fa\n", + " ID: book_440b6e0e-0f5b-4d2e-88d1-df873be9b0fa\n", " Total bids: 43\n", " Bid types: {'SIMPLE_HOURLY': 40, 'BLOCK': 1, 'LINKED_BLOCK': 1, 'EXCLUSIVE_GROUP': 1}\n", " Zones: ['NO1', 'NO2', 'NO5']\n", @@ -344,9 +360,7 @@ ], "source": [ "# Start with an empty book and add all bids\n", - "book = create_order_book(\n", - " metadata={\"desk\": \"solberg-trading\", \"delivery_date\": \"2026-03-15\"}\n", - ")\n", + "book = create_order_book(metadata={\"desk\": \"solberg-trading\", \"delivery_date\": \"2026-03-15\"})\n", "\n", "# Add simple bids\n", "book = add_bids(book, wind_bids)\n", @@ -382,10 +396,10 @@ "id": "d1b2c3d4-0004-0010-0001-000000000004", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:58.541705Z", - "iopub.status.busy": "2026-03-15T08:25:58.541613Z", - "iopub.status.idle": "2026-03-15T08:25:58.543982Z", - "shell.execute_reply": "2026-03-15T08:25:58.543797Z" + "iopub.execute_input": "2026-03-26T16:04:16.300250Z", + "iopub.status.busy": "2026-03-26T16:04:16.300195Z", + "iopub.status.idle": "2026-03-26T16:04:16.302275Z", + "shell.execute_reply": "2026-03-26T16:04:16.302064Z" } }, "outputs": [ @@ -412,16 +426,17 @@ "linked_bids = get_bids_by_type(book, BidType.LINKED_BLOCK)\n", "exclusive_bids = get_bids_by_type(book, BidType.EXCLUSIVE_GROUP)\n", "\n", - "print(f\"Block types: BLOCK={len(block_bids)}, LINKED={len(linked_bids)}, EXCLUSIVE={len(exclusive_bids)}\")\n", + "print(\n", + " f\"Block types: BLOCK={len(block_bids)}, LINKED={len(linked_bids)}, EXCLUSIVE={len(exclusive_bids)}\"\n", + ")\n", "\n", "# Filter using a custom predicate — e.g. only high-price bids (≥ EUR 60)\n", "from nexa_bidkit.bids import BlockBid, LinkedBlockBid\n", "\n", "high_price_book = filter_bids(\n", " book,\n", - " lambda bid: (\n", - " isinstance(bid, (BlockBid, LinkedBlockBid)) and float(bid.price) >= 60.0\n", - " ) or bid.bid_type == BidType.SIMPLE_HOURLY\n", + " lambda bid: (isinstance(bid, (BlockBid, LinkedBlockBid)) and float(bid.price) >= 60.0)\n", + " or bid.bid_type == BidType.SIMPLE_HOURLY,\n", ")\n", "\n", "print(f\"After high-price filter: {len(high_price_book.bids)} bids remain\")" @@ -443,10 +458,10 @@ "id": "d1b2c3d4-0004-0012-0001-000000000004", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:58.545056Z", - "iopub.status.busy": "2026-03-15T08:25:58.544976Z", - "iopub.status.idle": "2026-03-15T08:25:58.548071Z", - "shell.execute_reply": "2026-03-15T08:25:58.547793Z" + "iopub.execute_input": "2026-03-26T16:04:16.303187Z", + "iopub.status.busy": "2026-03-26T16:04:16.303140Z", + "iopub.status.idle": "2026-03-26T16:04:16.305308Z", + "shell.execute_reply": "2026-03-26T16:04:16.305127Z" } }, "outputs": [ @@ -475,18 +490,20 @@ "print(f\" Passed: {summary['passed']}\")\n", "print(f\" Failed: {summary['failed']}\")\n", "print(f\" Pass rate: {summary['pass_rate']:.1f}%\")\n", - "if summary['error_types']:\n", + "if summary[\"error_types\"]:\n", " print(f\" Error types: {summary['error_types']}\")\n", "else:\n", " print(\" All bids passed validation!\")\n", "\n", "# Also validate gate closure (raises if submission is after gate closure)\n", "gate_closure = datetime(2026, 3, 14, 12, 0, 0, tzinfo=CET) # 12:00 CET on D-1\n", - "now = datetime(2026, 3, 14, 10, 30, 0, tzinfo=CET) # current time (before gate)\n", + "now = datetime(2026, 3, 14, 10, 30, 0, tzinfo=CET) # current time (before gate)\n", "\n", "try:\n", " validate_order_book_for_submission(book, gate_closure_time=gate_closure, submission_time=now)\n", - " print(f\"\\nGate closure check PASSED (submission at {now.strftime('%H:%M')} < gate {gate_closure.strftime('%H:%M')} CET)\")\n", + " print(\n", + " f\"\\nGate closure check PASSED (submission at {now.strftime('%H:%M')} < gate {gate_closure.strftime('%H:%M')} CET)\"\n", + " )\n", "except ValidationError as e:\n", " print(f\"\\nGate closure check FAILED: {e}\")" ] @@ -497,10 +514,10 @@ "id": "d1b2c3d4-0004-0013-0001-000000000004", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:58.549450Z", - "iopub.status.busy": "2026-03-15T08:25:58.549351Z", - "iopub.status.idle": "2026-03-15T08:25:58.551827Z", - "shell.execute_reply": "2026-03-15T08:25:58.551564Z" + "iopub.execute_input": "2026-03-26T16:04:16.306108Z", + "iopub.status.busy": "2026-03-26T16:04:16.306062Z", + "iopub.status.idle": "2026-03-26T16:04:16.307659Z", + "shell.execute_reply": "2026-03-26T16:04:16.307428Z" } }, "outputs": [ @@ -551,10 +568,10 @@ "id": "d1b2c3d4-0004-0015-0001-000000000004", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:58.553012Z", - "iopub.status.busy": "2026-03-15T08:25:58.552939Z", - "iopub.status.idle": "2026-03-15T08:25:58.563770Z", - "shell.execute_reply": "2026-03-15T08:25:58.563541Z" + "iopub.execute_input": "2026-03-26T16:04:16.308600Z", + "iopub.status.busy": "2026-03-26T16:04:16.308539Z", + "iopub.status.idle": "2026-03-26T16:04:16.314169Z", + "shell.execute_reply": "2026-03-26T16:04:16.313953Z" } }, "outputs": [ @@ -595,8 +612,19 @@ "# Show block bid rows\n", "block_df = df[df[\"bid_type\"].isin([\"BLOCK\", \"LINKED_BLOCK\"])]\n", "print(f\"\\nBlock bids:\")\n", - "print(block_df[[\"bid_id\", \"bid_type\", \"bidding_zone\", \"price\",\n", - " \"volume_per_mtu\", \"mtu_count\", \"parent_bid_id\"]].to_string(index=False))" + "print(\n", + " block_df[\n", + " [\n", + " \"bid_id\",\n", + " \"bid_type\",\n", + " \"bidding_zone\",\n", + " \"price\",\n", + " \"volume_per_mtu\",\n", + " \"mtu_count\",\n", + " \"parent_bid_id\",\n", + " ]\n", + " ].to_string(index=False)\n", + ")" ] }, { @@ -615,10 +643,10 @@ "id": "d1b2c3d4-0004-0017-0001-000000000004", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:58.564887Z", - "iopub.status.busy": "2026-03-15T08:25:58.564817Z", - "iopub.status.idle": "2026-03-15T08:25:58.567071Z", - "shell.execute_reply": "2026-03-15T08:25:58.566875Z" + "iopub.execute_input": "2026-03-26T16:04:16.315141Z", + "iopub.status.busy": "2026-03-26T16:04:16.315092Z", + "iopub.status.idle": "2026-03-26T16:04:16.316901Z", + "shell.execute_reply": "2026-03-26T16:04:16.316703Z" } }, "outputs": [ @@ -667,10 +695,10 @@ "id": "d1b2c3d4-0004-0019-0001-000000000004", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:58.568118Z", - "iopub.status.busy": "2026-03-15T08:25:58.568051Z", - "iopub.status.idle": "2026-03-15T08:25:58.571239Z", - "shell.execute_reply": "2026-03-15T08:25:58.570928Z" + "iopub.execute_input": "2026-03-26T16:04:16.317897Z", + "iopub.status.busy": "2026-03-26T16:04:16.317825Z", + "iopub.status.idle": "2026-03-26T16:04:16.320421Z", + "shell.execute_reply": "2026-03-26T16:04:16.320234Z" } }, "outputs": [ @@ -704,10 +732,10 @@ " minute = mtu.start.astimezone(CET).minute\n", " return f\"{zone.value}-{hour:02d}{minute:02d}\"\n", "\n", + "\n", "# Export a sample of NO2 simple bids to a small order book\n", "no2_simple_bids = [\n", - " b for b in validated_book.bids\n", - " if isinstance(b, SimpleBid) and b.bidding_zone == BiddingZone.NO2\n", + " b for b in validated_book.bids if isinstance(b, SimpleBid) and b.bidding_zone == BiddingZone.NO2\n", "]\n", "print(f\"Total NO2 simple bids: {len(no2_simple_bids)}\")\n", "\n", @@ -752,10 +780,10 @@ "id": "d1b2c3d4-0004-0021-0001-000000000004", "metadata": { "execution": { - "iopub.execute_input": "2026-03-15T08:25:58.572403Z", - "iopub.status.busy": "2026-03-15T08:25:58.572328Z", - "iopub.status.idle": "2026-03-15T08:25:58.797624Z", - "shell.execute_reply": "2026-03-15T08:25:58.797286Z" + "iopub.execute_input": "2026-03-26T16:04:16.321312Z", + "iopub.status.busy": "2026-03-26T16:04:16.321258Z", + "iopub.status.idle": "2026-03-26T16:04:16.466896Z", + "shell.execute_reply": "2026-03-26T16:04:16.466631Z" } }, "outputs": [ @@ -783,25 +811,37 @@ "fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n", "fig.suptitle(\n", " f\"Solberg Trading Desk — Order Book Summary ({delivery_day.strftime('%Y-%m-%d')})\",\n", - " fontsize=13, fontweight=\"bold\"\n", + " fontsize=13,\n", + " fontweight=\"bold\",\n", ")\n", "\n", "# ---- Plot 1: Bid type distribution ----\n", "bid_counts = {k: v for k, v in summary[\"bid_counts\"].items() if v > 0}\n", - "type_colors = {\"SIMPLE_HOURLY\": \"#3498db\", \"BLOCK\": \"#e67e22\",\n", - " \"LINKED_BLOCK\": \"#e74c3c\", \"EXCLUSIVE_GROUP\": \"#9b59b6\"}\n", + "type_colors = {\n", + " \"SIMPLE_HOURLY\": \"#3498db\",\n", + " \"BLOCK\": \"#e67e22\",\n", + " \"LINKED_BLOCK\": \"#e74c3c\",\n", + " \"EXCLUSIVE_GROUP\": \"#9b59b6\",\n", + "}\n", "axes[0].bar(\n", " [t.replace(\"_\", \"\\n\") for t in bid_counts.keys()],\n", " bid_counts.values(),\n", " color=[type_colors.get(t, \"#95a5a6\") for t in bid_counts.keys()],\n", - " edgecolor=\"white\"\n", + " edgecolor=\"white\",\n", ")\n", "axes[0].set_title(\"Bids by Type\")\n", "axes[0].set_ylabel(\"Count\")\n", "axes[0].grid(True, axis=\"y\", alpha=0.3)\n", "for bar, count in zip(axes[0].patches, bid_counts.values()):\n", - " axes[0].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.3,\n", - " str(count), ha=\"center\", va=\"bottom\", fontsize=10, fontweight=\"bold\")\n", + " axes[0].text(\n", + " bar.get_x() + bar.get_width() / 2,\n", + " bar.get_height() + 0.3,\n", + " str(count),\n", + " ha=\"center\",\n", + " va=\"bottom\",\n", + " fontsize=10,\n", + " fontweight=\"bold\",\n", + " )\n", "\n", "# ---- Plot 2: Volume by bidding zone ----\n", "vol_by_zone = total_volume_by_zone(validated_book)\n", @@ -809,19 +849,31 @@ "zone_volumes = [float(v) for v in vol_by_zone.values()]\n", "zone_colors = [\"#27ae60\", \"#2980b9\", \"#f39c12\"]\n", "\n", - "bars = axes[1].bar(zone_labels, zone_volumes, color=zone_colors[:len(zone_labels)], edgecolor=\"white\")\n", + "bars = axes[1].bar(\n", + " zone_labels, zone_volumes, color=zone_colors[: len(zone_labels)], edgecolor=\"white\"\n", + ")\n", "axes[1].set_title(\"Total Bid Volume by Zone\")\n", "axes[1].set_ylabel(\"Volume (MW·MTUs)\")\n", "axes[1].grid(True, axis=\"y\", alpha=0.3)\n", "for bar, vol in zip(bars, zone_volumes):\n", - " axes[1].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 10,\n", - " f\"{vol:,.0f}\", ha=\"center\", va=\"bottom\", fontsize=9)\n", + " axes[1].text(\n", + " bar.get_x() + bar.get_width() / 2,\n", + " bar.get_height() + 10,\n", + " f\"{vol:,.0f}\",\n", + " ha=\"center\",\n", + " va=\"bottom\",\n", + " fontsize=9,\n", + " )\n", "\n", "# ---- Plot 3: Status donut ----\n", "status_counts = {k: v for k, v in summary[\"statuses\"].items() if v > 0}\n", - "status_colors = {\"DRAFT\": \"#bdc3c7\", \"VALIDATED\": \"#2ecc71\",\n", - " \"SUBMITTED\": \"#3498db\", \"ACCEPTED\": \"#27ae60\",\n", - " \"REJECTED\": \"#e74c3c\"}\n", + "status_colors = {\n", + " \"DRAFT\": \"#bdc3c7\",\n", + " \"VALIDATED\": \"#2ecc71\",\n", + " \"SUBMITTED\": \"#3498db\",\n", + " \"ACCEPTED\": \"#27ae60\",\n", + " \"REJECTED\": \"#e74c3c\",\n", + "}\n", "wedge_colors = [status_colors.get(s, \"#95a5a6\") for s in status_counts.keys()]\n", "\n", "wedges, texts, autotexts = axes[2].pie(\n", @@ -885,7 +937,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.8" + "version": "3.14.3" } }, "nbformat": 4, diff --git a/reference/exaa.md b/reference/exaa.md new file mode 100644 index 0000000..df0782a --- /dev/null +++ b/reference/exaa.md @@ -0,0 +1,508 @@ +# EXAA Order Model Reference + +Reference document for EXAA (Energy Exchange Austria) Trading API order structures. +This describes the exchange's order model, validation rules, and wire format as documented +in the EXAA Trading API Technical Description v1.6, OpenAPI spec, and supporting documents. + +This is a domain reference, not an implementation spec. Use it to inform design decisions +when building EXAA exchange support into nexa-bidkit. + +--- + +## Auction Types + +EXAA runs two day-ahead auction types. No intraday auctions or continuous markets. + +### Classic Auction (10:15 CET) + +EXAA's own local clearing for Austria, Germany, and the Netherlands. +Auction ID format: `Classic_YYYY-MM-DD` (delivery date). + +Supports all three product types: hourly, block, and 15-minute. +Has a unique post-trading phase after clearing where residual volume is available +at the clearing price. + +### Market Coupling Auction (12:00 CET) + +SDAC/EUPHEMIA market coupling. +Auction ID format: `MC_YYYY-MM-DD` (delivery date). + +Supports hourly and block products only. No 15-minute products. +Block orders are restricted to STEP + fill-or-kill only. + +--- + +## Product Types + +Products are returned dynamically per auction via `GET /auctions/{auction-id}`. +Product IDs are exchange-assigned strings. The delivery time periods are provided +alongside each product ID in the auction response. + +### Hourly Products + +One product per delivery hour. Available in both auction types. + +Example IDs and their delivery periods: +``` +hEXA01 -> [{from: "00:00:00", to: "01:00:00"}] +hEXA02 -> [{from: "01:00:00", to: "02:00:00"}] +... +hEXA24 -> [{from: "23:00:00", to: "24:00:00"}] +``` + +### 15-Minute Products + +One product per quarter-hour. Classic auction only. Not available for time spread accounts. + +Example IDs and their delivery periods: +``` +qEXA01_1 (00:00 - 00:15) -> [{from: "00:00:00", to: "00:15:00"}] +qEXA01_2 (00:15 - 00:30) -> [{from: "00:15:00", to: "00:30:00"}] +qEXA01_3 (00:30 - 00:45) -> [{from: "00:30:00", to: "00:45:00"}] +qEXA01_4 (00:45 - 01:00) -> [{from: "00:45:00", to: "01:00:00"}] +... +``` + +### Block Products + +Products spanning multiple delivery hours. Some have non-contiguous delivery periods. + +Example IDs and their delivery periods: +``` +bEXAbase (01-24) -> [{from: "00:00:00", to: "24:00:00"}] +bEXApeak (09-20) -> [{from: "08:00:00", to: "20:00:00"}] +bEXAoffpeak (01-08_21-24) -> [{from: "00:00:00", to: "08:00:00"}, + {from: "20:00:00", to: "24:00:00"}] +bEXAdream (01-06) -> [{from: "00:00:00", to: "06:00:00"}] +bEXAearlytwin (09-10) -> [{from: "08:00:00", to: "10:00:00"}] +bEXAlunch (11-14) -> [{from: "10:00:00", to: "14:00:00"}] +``` + +Note: Block delivery periods use non-contiguous arrays for products like off-peak. +Location spread block products are limited to BASELOAD and PEAK only. + +--- + +## Trade Accounts + +Orders are grouped by trade account. Each account has: + +| Field | Type | Description | +|-------|------|-------------| +| accountID | string | Identifier, e.g. "APTAP1" | +| controlArea | string | TSO area: "APG" (AT), "Amprion" (DE), "TenneT" (NL/DE) | +| isSpreadAccount | boolean | True for location spread accounts | +| accountIDSink | string or null | Sink account for spread relationships | +| controlAreaSink | string or null | Sink TSO area | +| originType | enum | "GREY", "GREEN", or "MARKET_COUPLING" | +| constraints | object | Per-account trading limits (see below) | + +### Account Constraints + +Returned per account in the auction response. All nullable (null = no limit). + +| Field | Type | Description | +|-------|------|-------------| +| maxVolume | number or null | Maximum volume per product (MWh/h) | +| maxPriceBuy | number or null | Maximum buy price (EUR/MWh) | +| minPriceSell | number or null | Minimum sell price (EUR/MWh) | + +--- + +## Order Submission Format + +Orders are submitted via `POST /exaa-trading-api/V1/auctions/{auction-id}/orders`. + +**Critical: this is a full-replacement operation per account.** All existing orders for any +account included in the request are completely replaced. Orders for accounts NOT +mentioned in the request are untouched. + +### Top-Level Structure + +```json +{ + "units": { + "price": "EUR", + "volume": "MWh/h" + }, + "orders": [ + { + "accountID": "APTAP1", + "isSpreadOrder": false, + "accountIDSink": null, + "hourlyProducts": { ... }, + "blockProducts": { ... }, + "15minProducts": { ... } + } + ] +} +``` + +The `units` object is fixed: `{"price": "EUR", "volume": "MWh/h"}`. + +### Product Type Container + +Each of `hourlyProducts`, `blockProducts`, and `15minProducts` shares this structure: + +```json +{ + "typeOfOrder": "LINEAR", + "products": [ + { + "productID": "hEXA01", + "fillOrKill": false, + "priceVolumePairs": [ + {"price": 40.00, "volume": 250}, + {"price": 43.95, "volume": 150} + ] + } + ] +} +``` + +If a product type container is `null` or absent, no products of that type are bid on. +**However, if the container is present with a null or empty products list, any existing +orders of that product type for the specified account are deleted.** + +`typeOfOrder` is set at the product-type level and applies to ALL products within +that container. It cannot differ between products of the same type. + +### Price/Volume Pairs + +| Field | Type | Description | +|-------|------|-------------| +| price | number or "M" | EUR/MWh, 2 decimal places. Or string "M" for market orders. | +| volume | number | MWh/h, 1 decimal place. Positive = buy, negative = sell. | + +### Market Orders + +A market order has no specific price. Submitted as: +```json +{"price": "M", "volume": 100} +``` + +Only one market order is allowed per product. +Market orders cannot be used with LINEAR type (only STEP). + +--- + +## Order Type Rules + +`typeOfOrder` can be `LINEAR` or `STEP`: + +- **STEP**: Volume is allocated at each price level independently (step function). +- **LINEAR**: Volume is interpolated linearly between price/volume points. + +### Allowed Combinations by Auction Type + +#### Classic Auction (10:15) - Normal Order Submission + +| Product Type | Account Type | typeOfOrder | fillOrKill | +|-------------|--------------|-------------|------------| +| 15minProducts | grey | STEP or LINEAR | false only | +| hourlyProducts | grey or green | STEP or LINEAR | false only | +| blockProducts | grey or green | STEP or LINEAR | STEP: true or false; LINEAR: false only | +| blockProducts (location spread, BASE/PEAK only) | grey | STEP or LINEAR | STEP: true or false; LINEAR: false only | + +#### Market Coupling Auction (12:00) + +| Product Type | Account Type | typeOfOrder | fillOrKill | +|-------------|--------------|-------------|------------| +| hourlyProducts | grey | STEP or LINEAR | false only | +| blockProducts | grey | STEP only | true only | + +#### Classic Auction - Post-Trading Order Submission + +| Product Type | Account Type | typeOfOrder | fillOrKill | +|-------------|--------------|-------------|------------| +| hourlyProducts | grey | n/a | true or false | +| blockProducts | grey | n/a | true or false | + +--- + +## Validation Rules + +### Price Constraints + +- Prices must be in range: -500 < price < 4000 EUR/MWh (exclusive on both ends). +- Orders submitted at exactly -500 or 4000 are rejected. Use market orders ("M") instead. +- Prices are EUR/MWh with exactly 2 decimal places. + +### Volume Constraints + +- Volumes are MWh/h with exactly 1 decimal place. +- Positive = buy, negative = sell. +- Zero-volume-only bids are invalid (error F034). +- Per-account constraints (maxVolume, maxPriceBuy, minPriceSell) are enforced server-side. + +### Curve Rules + +- **Monotonic rule (F010)**: Bids must be monotonic per direction (buy/sell) within a product. +- **Distinct prices (F013)**: All prices within a single product's price/volume pairs must be distinct. +- **Max pairs**: No more than 30,000 price/volume pairs per POST request. +- **Max body size**: 2 MB per request. + +### Product Rules + +- Duplicate product IDs within a product type are rejected (F020). +- Product IDs must match those returned by the auction (F015). +- Product types must be valid for the auction type (F015, F019). + +--- + +## Location Spread Orders + +Location spread orders trade between two control areas (e.g. AT <-> DE). + +Submitted by setting: +```json +{ + "accountID": "ACCOUNT_AT", + "isSpreadOrder": true, + "accountIDSink": "ACCOUNT_DE" +} +``` + +Rules: +- First account (source) is always Austrian or Dutch leg. +- Second account (sink) is always the German leg. +- Only BASELOAD and PEAK block products are available. +- To delete, specify the source account only. Sink account in DELETE is rejected (F032). +- Source and sink must be different accounts (F017). + +## Time Spread Orders + +Time spread accounts follow normal Classic account submission rules, except: +- 15minProducts are NOT available. +- `isSpreadOrder` must be `false`. +- `accountIDSink` must be `null`. +- POST/DELETE with MC accounts in a time spread relationship are rejected. + +--- + +## Post-Trading Orders + +Post-trading is unique to the Classic (10:15) auction. After clearing, residual volume +becomes available at the clearing price. + +### Post-Trading Info Response + +Available once auction reaches `POSTTRADE_OPEN` state. + +Per product: `productID`, `price` (clearing price), `volumeAvailable` +(positive = buy volume available, negative = sell volume available). + +Products are grouped into hourlyProducts, blockProducts, 15minProducts. + +### Post-Trading Order Format + +Simpler than normal orders. No price (clearing price is used). No typeOfOrder. + +```json +{ + "units": {"price": "EUR", "volume": "MWh/h"}, + "orders": [ + { + "accountID": "APTAP1", + "hourlyProducts": [ + {"productID": "hEXA01", "fillOrKill": false, "volume": 12} + ], + "blockProducts": [], + "15minProducts": [] + } + ] +} +``` + +Post-trading constraints: +- Grey accounts only (no green, no MC, no spread - F028). +- Volume must not exceed `volumeAvailable` (F024). +- Volume must not exceed user-specific limits (F025). +- Buy must be buy and sell must be sell - you cannot reverse direction (F027). +- fillOrKill: both true and false are allowed. + +--- + +## Results Format + +### Trade Results + +Per-account results. Available from `AUCTIONED` (Classic) or `PRELIMINARY_RESULTS` (MC). +Final once auction reaches `FINALIZED`. + +Per product: +```json +{"productID": "hEXA01", "price": 42.40, "volumeAwarded": 50.0} +``` +`volumeAwarded`: positive = bought, negative = sold. + +### Market Results + +Market-wide results. Same availability timing as trade results. + +Per product, per price zone: +```json +{ + "productID": "hEXA01", + "originType": "GREY", + "priceZones": [ + {"priceZoneID": "AT", "price": 40.00, "volume": 250}, + {"priceZoneID": "DE", "price": 43.95, "volume": 150} + ] +} +``` + +Price zones are typically: AT, DE, NL (depending on auction configuration). + +### Trade Confirmations + +Final confirmations. Available only at `FINALIZED` / `FINALIZED_FALLBACK`. +Shows all trades for all accounts linked to the company. + +Per confirmation: +```json +{ + "companyID": "Company1", + "accountID": "APTAP1", + "controlArea": "APG", + "originType": "GREY", + "productID": "bEXAbase (01-24)", + "buyOrSell": "sell", + "price": 43.01, + "volume": 300, + "referenceNumber": "0034402520191027" +} +``` + +--- + +## Auction Lifecycle States + +### Classic Auction + +``` +TRADE_OPEN -> TRADE_CLOSED -> AUCTIONING -> AUCTIONED -> +POSTTRADE_OPEN -> POSTTRADE_CLOSED -> POSTAUCTIONING -> POSTAUCTIONED -> FINALIZED +``` + +| State | Orders | Post-Trading | Results | Confirmations | +|-------|--------|-------------|---------|---------------| +| TRADE_OPEN | read + write | - | - | - | +| TRADE_CLOSED | read only | - | - | - | +| AUCTIONING | read only | - | - | - | +| AUCTIONED | read only | - | preliminary | - | +| POSTTRADE_OPEN | read only | read + write | preliminary | - | +| POSTTRADE_CLOSED | read only | read only | preliminary | - | +| POSTAUCTIONING | read only | read only | preliminary | - | +| POSTAUCTIONED | read only | read only | preliminary | - | +| FINALIZED | read only | read only | final | available | + +### Market Coupling Auction + +Normal flow: +``` +TRADE_OPEN -> TRADE_CLOSED -> AUCTIONING -> PRELIMINARY_RESULTS -> FINALIZED +``` + +Fallback flow (if SDAC coupling fails): +``` +TRADE_OPEN_FALLBACK -> TRADE_CLOSED_FALLBACK -> AUCTIONING_FALLBACK -> +AUCTIONED_FALLBACK -> FINALIZED_FALLBACK +``` + +| State | Orders | Results | Confirmations | +|-------|--------|---------|---------------| +| TRADE_OPEN / _FALLBACK | read + write | - | - | +| TRADE_CLOSED / _FALLBACK | read only | - | - | +| AUCTIONING / _FALLBACK | read only | - | - | +| PRELIMINARY_RESULTS / AUCTIONED_FALLBACK | read only | preliminary | - | +| FINALIZED / FINALIZED_FALLBACK | read only | final | available | + +--- + +## Error Codes + +Errors are returned as: +```json +{ + "errors": [ + {"code": "F010", "message": "Monotonic rule is violated", "path": "[json path]"} + ] +} +``` + +### Error Code Reference + +**Authentication (Axxx) - HTTP 403:** +- A001: Username/PIN wrong +- A002: Passcode (RSA) wrong or expired +- A003: Passcode (RSA) missing +- A004: Forbidden (not authorised for function) + +**Syntax (Sxxx) - HTTP 400:** +- S001: Unrecognized JSON field +- S002: Invalid value type +- S003: JSON parsing error +- S004: Type mismatch +- S005: Invalid query parameter format + +**Functional (Fxxx) - HTTP 400/404/409:** +- F001: Post-trading not available (not Classic auction) [404] +- F002-F004: Results not yet available (auction state too early) [409] +- F005: Auction not yet in post-trading state [409] +- F006: Auction not found or closed [409] +- F007: Trade account not found or not accessible [400] +- F008: Auction not open for trading [409] +- F009: Invalid units [400] +- F010: Monotonic rule violated [400] +- F011: Account/auction spread mismatch [400] +- F012: Trade constraint violated [400] +- F013: Prices not distinct [400] +- F014: Invalid fillOrKill for auction/product/type combination [400] +- F015: Invalid product type or product ID [400] +- F016: Invalid typeOfOrder for auction/context [400] +- F017: Source and sink accounts must differ [400] +- F018: No trade account provided [400] +- F019: Product type not allowed in post-trading [400] +- F020: Duplicate products [400] +- F021: Volume or price exceeds global limit [400] +- F022: Values exceed auction-specific limits (use "M" for market orders at boundary) [400] +- F023: Too many price/volume pairs [400] +- F024: No volume available (post-trading) [400] +- F025: Post-trading volume exceeds user limits [400] +- F026: Trade zone not valid for auction [400] +- F027: Post-trading direction mismatch [400] +- F028: Spread accounts not allowed in post-trading [400] +- F029: Too many market orders (max 1 per product) [400] +- F030: Account origin type doesn't match auction [400] +- F031: Price zone mismatch [400] +- F032: Sink account not allowed (use source for DELETE) [400] +- F033: Internal error - too many trade relations [400] +- F034: Zero volume bids [400] + +**Request (Rxxx):** +- R001: Method not allowed [405] +- R002: Resource not found [404] +- R003: Unsupported media type [415] +- R004: Request body too large [400] + +**Value (Vxxx) - HTTP 400:** +- V001: Value must not be zero +- V002: Invalid decimal format +- V003: Value must not be null or empty +- V004: Value must not be null +- V005: Value must match pattern + +**Unspecific (Uxxx):** +- U001: Unhandled server error [500] +- U002: Unhandled client error [400] + +--- + +## Source Documents + +This reference was compiled from: +- EXAA Trading API Technical Description v1.6 (July 2025) +- EXAA Trading API OpenAPI Specification (exaa-trading-api.yaml) +- EXAA Trading API General Information v1.1 (November 2023) +- EXAA Trading API 2-Factor Authentication Extension v3.2 (November 2025) diff --git a/src/nexa_bidkit/exaa.py b/src/nexa_bidkit/exaa.py new file mode 100644 index 0000000..e22d3f7 --- /dev/null +++ b/src/nexa_bidkit/exaa.py @@ -0,0 +1,482 @@ +"""EXAA market adapter for nexa-bidkit. + +Converts internal bid objects into EXAA Trading API order submission payloads. +Supports Classic (10:15 CET) and Market Coupling (12:00 CET) auction types. + +EXAA operates in Austria (AT), Germany (DE-LU), and the Netherlands (NL). +Unsupported bidding zones raise :class:`ValueError`. + +The EXAA submission model groups bids by trade account. Callers supply an +``account_id`` string identifying the account to submit under. For portfolios +spanning multiple accounts, call :func:`order_book_to_exaa` once per account +with a filtered :class:`~nexa_bidkit.orders.OrderBook`. + +Product IDs are exchange-assigned and returned dynamically per auction via +``GET /auctions/{auction-id}``. Callers must supply a :data:`ProductIdResolver` +to map MTU intervals to product ID strings. For convenience, the standard +helpers :func:`standard_hourly_product_id` and +:func:`standard_quarter_hourly_product_id` generate the well-known IDs +(``hEXA01``–``hEXA24`` and ``qEXA01_1``–``qEXA24_4``) directly from the MTU +start time — useful when the auction's product list follows the canonical +pattern. + +.. note:: + EXAA volume sign convention is **opposite** to Nord Pool: + positive volume = buy, negative volume = sell. + +.. note:: + Linked block bids and exclusive groups are not supported by EXAA. + Passing these bid types to :func:`order_book_to_exaa` raises + :class:`ValueError`. +""" + +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from nexa_bidkit.bids import BlockBid, ExclusiveGroupBid, LinkedBlockBid, SimpleBid +from nexa_bidkit.orders import OrderBook +from nexa_bidkit.types import BiddingZone, DeliveryPeriod, Direction, MTUDuration, MTUInterval + +# --------------------------------------------------------------------------- +# Type aliases +# --------------------------------------------------------------------------- + +ProductIdResolver = Callable[[MTUInterval], str] +"""Callable that maps an MTU interval to an EXAA product ID string. + +Used for hourly products (e.g. ``"hEXA01"``) and 15-minute products +(e.g. ``"qEXA01_1"``). +""" + +BlockProductResolver = Callable[[DeliveryPeriod], str] +"""Callable that maps a delivery period to an EXAA block product ID string. + +Used for block products (e.g. ``"bEXAbase (01-24)"``, ``"bEXApeak (09-20)"``). +""" + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class ExaaOrderType(str, Enum): + """Order type for EXAA price-volume pairs. + + Attributes: + STEP: Volume is allocated at each price level independently. + LINEAR: Volume is interpolated linearly between price/volume points. + """ + + STEP = "STEP" + LINEAR = "LINEAR" + + +# --------------------------------------------------------------------------- +# EXAA request Pydantic models +# --------------------------------------------------------------------------- + + +class PriceVolumePair(BaseModel): + """A single price-volume pair in an EXAA product order. + + Attributes: + price: Price in EUR/MWh (2 decimal places), or ``"M"`` for a market order. + volume: Volume in MWh/h. Positive = buy, negative = sell. + """ + + model_config = ConfigDict(populate_by_name=True) + + price: float | Literal["M"] + volume: float + + +class ExaaProduct(BaseModel): + """An EXAA product order (hourly, 15-minute, or block). + + Attributes: + product_id: Exchange-assigned product identifier (e.g. ``"hEXA01"``). + fill_or_kill: If ``True`` the order must be fully filled or not at all. + price_volume_pairs: Ordered list of price-volume pairs. + """ + + model_config = ConfigDict(populate_by_name=True) + + product_id: str = Field(alias="productID") + fill_or_kill: bool = Field(alias="fillOrKill") + price_volume_pairs: list[PriceVolumePair] = Field(alias="priceVolumePairs") + + +class ExaaProductTypeContainer(BaseModel): + """Container for a single product type (hourly, block, or 15-minute). + + ``typeOfOrder`` applies to **all** products within this container. + + Attributes: + type_of_order: STEP or LINEAR interpolation for all products. + products: List of product orders in this container. + """ + + model_config = ConfigDict(populate_by_name=True) + + type_of_order: ExaaOrderType = Field(alias="typeOfOrder") + products: list[ExaaProduct] + + +class ExaaAccountOrder(BaseModel): + """EXAA order submission for a single trade account. + + Omitting a product-type container (``None``) leaves existing orders of + that type unchanged. Setting a container with an empty ``products`` list + **deletes** all existing orders of that type for the account. + + Attributes: + account_id: Trade account identifier (e.g. ``"APTAP1"``). + is_spread_order: ``True`` for location spread orders. + account_id_sink: Sink account for spread orders; ``None`` otherwise. + hourly_products: Hourly product container, or ``None`` to leave unchanged. + block_products: Block product container, or ``None`` to leave unchanged. + fifteen_min_products: 15-minute product container, or ``None`` to leave unchanged. + """ + + model_config = ConfigDict(populate_by_name=True) + + account_id: str = Field(alias="accountID") + is_spread_order: bool = Field(default=False, alias="isSpreadOrder") + account_id_sink: str | None = Field(default=None, alias="accountIDSink") + hourly_products: ExaaProductTypeContainer | None = Field(default=None, alias="hourlyProducts") + block_products: ExaaProductTypeContainer | None = Field(default=None, alias="blockProducts") + fifteen_min_products: ExaaProductTypeContainer | None = Field( + default=None, alias="15minProducts" + ) + + +class ExaaUnits(BaseModel): + """Fixed units object required by the EXAA API. + + Always ``{"price": "EUR", "volume": "MWh/h"}``. + """ + + model_config = ConfigDict(populate_by_name=True) + + price: str = "EUR" + volume: str = "MWh/h" + + +class ExaaOrderRequest(BaseModel): + """Top-level EXAA order submission payload. + + Serialise with ``model_dump(by_alias=True)`` to produce the wire format + expected by ``POST /exaa-trading-api/V1/auctions/{auction-id}/orders``. + + Attributes: + units: Fixed units declaration (always EUR / MWh/h). + orders: Per-account order entries. + """ + + model_config = ConfigDict(populate_by_name=True) + + units: ExaaUnits = Field(default_factory=ExaaUnits) + orders: list[ExaaAccountOrder] + + +# --------------------------------------------------------------------------- +# Bidding zone → control area +# --------------------------------------------------------------------------- + +_CONTROL_AREA_MAP: dict[BiddingZone, str] = { + BiddingZone.AT: "APG", + BiddingZone.DE_LU: "Amprion", + BiddingZone.NL: "TenneT", +} + + +def bidding_zone_to_control_area(zone: BiddingZone) -> str: + """Convert a :class:`~nexa_bidkit.types.BiddingZone` to an EXAA control area string. + + Args: + zone: The bidding zone to convert. + + Returns: + The EXAA control area string (``"APG"``, ``"Amprion"``, or ``"TenneT"``). + + Raises: + ValueError: If the zone is not supported by EXAA (AT, DE-LU, NL only). + """ + try: + return _CONTROL_AREA_MAP[zone] + except KeyError as exc: + raise ValueError( + f"Bidding zone {zone.value!r} is not supported by EXAA. " + "EXAA operates in Austria (AT), Germany (DE-LU), and the Netherlands (NL) only." + ) from exc + + +# --------------------------------------------------------------------------- +# Standard product ID helpers +# --------------------------------------------------------------------------- + + +def standard_hourly_product_id(mtu: MTUInterval) -> str: + """Generate the standard EXAA hourly product ID for a given MTU interval. + + Maps hour 00:00–01:00 → ``"hEXA01"``, 01:00–02:00 → ``"hEXA02"``, and so + on through 23:00–24:00 → ``"hEXA24"``. + + The hour number is derived from ``mtu.start`` in whatever timezone it + carries. For EXAA (CET/CEST) submissions, ensure the MTU uses a + CET-aware datetime before calling this helper. + + Args: + mtu: The MTU interval to derive the product ID from. + + Returns: + Product ID string such as ``"hEXA01"``. + """ + hour_num = mtu.start.hour + 1 + return f"hEXA{hour_num:02d}" + + +def standard_quarter_hourly_product_id(mtu: MTUInterval) -> str: + """Generate the standard EXAA 15-minute product ID for a given MTU interval. + + Maps 00:00–00:15 → ``"qEXA01_1"``, 00:15–00:30 → ``"qEXA01_2"``, and so + on through 23:45–24:00 → ``"qEXA24_4"``. + + The hour and quarter numbers are derived from ``mtu.start`` in whatever + timezone it carries. For EXAA submissions, ensure the MTU uses a + CET-aware datetime before calling this helper. + + Args: + mtu: The MTU interval to derive the product ID from. + + Returns: + Product ID string such as ``"qEXA01_1"``. + """ + hour_num = mtu.start.hour + 1 + quarter_num = mtu.start.minute // 15 + 1 + return f"qEXA{hour_num:02d}_{quarter_num}" + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _signed_volume(volume: float, direction: Direction) -> float: + """Return volume with EXAA sign convention: BUY = positive, SELL = negative. + + Note: This is the **opposite** of the Nord Pool convention. + """ + return volume if direction == Direction.BUY else -volume + + +# --------------------------------------------------------------------------- +# Conversion functions +# --------------------------------------------------------------------------- + + +def simple_bid_to_exaa_product( + bid: SimpleBid, + product_id_resolver: ProductIdResolver, + fill_or_kill: bool = False, +) -> ExaaProduct: + """Convert a :class:`~nexa_bidkit.bids.SimpleBid` to an :class:`ExaaProduct`. + + Each :class:`~nexa_bidkit.types.PriceQuantityStep` in the bid's curve + becomes a :class:`PriceVolumePair`. Volumes are signed per EXAA convention: + positive for BUY, negative for SELL. + + Args: + bid: The simple bid to convert. + product_id_resolver: Callable mapping the bid's MTU to an EXAA product ID. + fill_or_kill: Whether this product must be fully filled or not at all. + Per EXAA rules, hourly and 15-minute products should always use + ``False``; this parameter is exposed for completeness. + + Returns: + An :class:`ExaaProduct` ready to be placed in a product type container. + """ + product_id = product_id_resolver(bid.curve.mtu) + + pairs = [ + PriceVolumePair( + price=float(step.price), + volume=_signed_volume(float(step.volume), bid.direction), + ) + for step in bid.curve.steps + ] + + return ExaaProduct.model_validate( + { + "productID": product_id, + "fillOrKill": fill_or_kill, + "priceVolumePairs": pairs, + } + ) + + +def block_bid_to_exaa_product( + bid: BlockBid, + product_id: str, +) -> ExaaProduct: + """Convert a :class:`~nexa_bidkit.bids.BlockBid` to an :class:`ExaaProduct`. + + Creates a single :class:`PriceVolumePair` from the bid's price and volume. + ``fillOrKill`` is derived from :attr:`~nexa_bidkit.bids.BlockBid.is_indivisible` + (i.e. ``True`` when ``min_acceptance_ratio == 1.0``). + + EXAA block products have exchange-defined delivery periods (e.g. + ``"bEXAbase (01-24)"``, ``"bEXApeak (09-20)"``). Pass the correct product ID + for the desired block product. + + Args: + bid: The block bid to convert. + product_id: EXAA block product identifier (e.g. ``"bEXAbase (01-24)"``). + + Returns: + An :class:`ExaaProduct` ready to be placed in a block product container. + """ + pair = PriceVolumePair( + price=float(bid.price), + volume=_signed_volume(float(bid.volume), bid.direction), + ) + + return ExaaProduct.model_validate( + { + "productID": product_id, + "fillOrKill": bid.is_indivisible, + "priceVolumePairs": [pair], + } + ) + + +def order_book_to_exaa( + order_book: OrderBook, + account_id: str, + product_id_resolver: ProductIdResolver, + block_product_resolver: BlockProductResolver | None = None, + order_type: ExaaOrderType = ExaaOrderType.STEP, +) -> ExaaOrderRequest: + """Convert an :class:`~nexa_bidkit.orders.OrderBook` into an :class:`ExaaOrderRequest`. + + Dispatches each bid to the appropriate product type container: + + - :class:`~nexa_bidkit.bids.SimpleBid` with :attr:`~nexa_bidkit.types.MTUDuration.HOURLY` + duration → ``hourlyProducts`` + - :class:`~nexa_bidkit.bids.SimpleBid` with + :attr:`~nexa_bidkit.types.MTUDuration.QUARTER_HOURLY` duration → ``15minProducts`` + - :class:`~nexa_bidkit.bids.BlockBid` → ``blockProducts`` + (requires ``block_product_resolver``) + + Product type containers with no products are set to ``None`` (omitted when + serialising with ``model_dump(by_alias=True)``). + + All containers use the same ``order_type`` (LINEAR or STEP). + + Args: + order_book: The order book to convert. + account_id: EXAA trade account identifier for the submission. + product_id_resolver: Callable mapping an MTU interval to an EXAA + hourly or 15-minute product ID string. + block_product_resolver: Callable mapping a + :class:`~nexa_bidkit.types.DeliveryPeriod` to an EXAA block + product ID string. Required if the order book contains any + :class:`~nexa_bidkit.bids.BlockBid` entries. + order_type: STEP or LINEAR interpolation applied to all product + type containers. Defaults to :attr:`ExaaOrderType.STEP`. + + Returns: + An :class:`ExaaOrderRequest` ready for submission to the EXAA API. + + Raises: + ValueError: If the order book contains + :class:`~nexa_bidkit.bids.LinkedBlockBid` or + :class:`~nexa_bidkit.bids.ExclusiveGroupBid` entries, which are + not supported by EXAA. + ValueError: If the order book contains + :class:`~nexa_bidkit.bids.BlockBid` entries but + ``block_product_resolver`` is ``None``. + """ + hourly: list[ExaaProduct] = [] + fifteen_min: list[ExaaProduct] = [] + blocks: list[ExaaProduct] = [] + + for bid in order_book.bids: + if isinstance(bid, LinkedBlockBid): + raise ValueError( + f"Bid {bid.bid_id!r} is a LinkedBlockBid, which is not supported by EXAA. " + "EXAA does not have a linked block concept." + ) + if isinstance(bid, ExclusiveGroupBid): + raise ValueError( + f"Bid {bid.group_id!r} is an ExclusiveGroupBid, which is not supported by EXAA. " + "EXAA does not have an exclusive group concept." + ) + if isinstance(bid, SimpleBid): + product = simple_bid_to_exaa_product(bid, product_id_resolver) + if bid.curve.mtu.duration == MTUDuration.HOURLY: + hourly.append(product) + else: + fifteen_min.append(product) + elif isinstance(bid, BlockBid): + if block_product_resolver is None: + raise ValueError( + "order_book contains BlockBid entries but block_product_resolver is None. " + "Provide a BlockProductResolver to map delivery periods to block product IDs." + ) + product_id = block_product_resolver(bid.delivery_period) + blocks.append(block_bid_to_exaa_product(bid, product_id)) + + hourly_container = ( + ExaaProductTypeContainer.model_validate({"typeOfOrder": order_type, "products": hourly}) + if hourly + else None + ) + fifteen_min_container = ( + ExaaProductTypeContainer.model_validate( + {"typeOfOrder": order_type, "products": fifteen_min} + ) + if fifteen_min + else None + ) + block_container = ( + ExaaProductTypeContainer.model_validate({"typeOfOrder": order_type, "products": blocks}) + if blocks + else None + ) + + account_order = ExaaAccountOrder.model_validate( + { + "accountID": account_id, + "hourlyProducts": hourly_container, + "blockProducts": block_container, + "15minProducts": fifteen_min_container, + } + ) + + return ExaaOrderRequest(orders=[account_order]) + + +__all__ = [ + "ProductIdResolver", + "BlockProductResolver", + "ExaaOrderType", + "PriceVolumePair", + "ExaaProduct", + "ExaaProductTypeContainer", + "ExaaAccountOrder", + "ExaaUnits", + "ExaaOrderRequest", + "bidding_zone_to_control_area", + "standard_hourly_product_id", + "standard_quarter_hourly_product_id", + "simple_bid_to_exaa_product", + "block_bid_to_exaa_product", + "order_book_to_exaa", +] diff --git a/tests/test_exaa.py b/tests/test_exaa.py new file mode 100644 index 0000000..1b49b97 --- /dev/null +++ b/tests/test_exaa.py @@ -0,0 +1,537 @@ +"""Tests for nexa_bidkit.exaa — EXAA market adapter.""" + +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest + +from nexa_bidkit.bids import ( + BlockBid, + LinkedBlockBid, + SimpleBid, + exclusive_group, +) +from nexa_bidkit.exaa import ( + ExaaOrderRequest, + ExaaOrderType, + ExaaProduct, + ExaaProductTypeContainer, + bidding_zone_to_control_area, + block_bid_to_exaa_product, + order_book_to_exaa, + simple_bid_to_exaa_product, + standard_hourly_product_id, + standard_quarter_hourly_product_id, +) +from nexa_bidkit.orders import add_bid, create_order_book +from nexa_bidkit.types import ( + BiddingZone, + CurveType, + DeliveryPeriod, + Direction, + MTUDuration, + MTUInterval, + PriceQuantityCurve, + PriceQuantityStep, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +T0 = datetime(2026, 4, 1, 10, 0, 0, tzinfo=UTC) +ACCOUNT_ID = "APTAP1" + + +def _mtu(start: datetime = T0, duration: MTUDuration = MTUDuration.HOURLY) -> MTUInterval: + return MTUInterval.from_start(start, duration) + + +def _delivery_period( + start: datetime = T0, + hours: int = 2, + duration: MTUDuration = MTUDuration.HOURLY, +) -> DeliveryPeriod: + return DeliveryPeriod(start=start, end=start + timedelta(hours=hours), duration=duration) + + +def _supply_curve( + start: datetime = T0, duration: MTUDuration = MTUDuration.HOURLY +) -> PriceQuantityCurve: + return PriceQuantityCurve( + curve_type=CurveType.SUPPLY, + steps=[ + PriceQuantityStep(price=Decimal("10.00"), volume=Decimal("50")), + PriceQuantityStep(price=Decimal("20.00"), volume=Decimal("100")), + ], + mtu=_mtu(start, duration), + ) + + +def _demand_curve(start: datetime = T0) -> PriceQuantityCurve: + return PriceQuantityCurve( + curve_type=CurveType.DEMAND, + steps=[ + PriceQuantityStep(price=Decimal("80.00"), volume=Decimal("30")), + PriceQuantityStep(price=Decimal("40.00"), volume=Decimal("60")), + ], + mtu=_mtu(start), + ) + + +def _simple_sell_bid(start: datetime = T0, duration: MTUDuration = MTUDuration.HOURLY) -> SimpleBid: + return SimpleBid( + bid_id="simple-sell-1", + bidding_zone=BiddingZone.AT, + direction=Direction.SELL, + curve=_supply_curve(start, duration), + ) + + +def _simple_buy_bid(start: datetime = T0) -> SimpleBid: + return SimpleBid( + bid_id="simple-buy-1", + bidding_zone=BiddingZone.AT, + direction=Direction.BUY, + curve=_demand_curve(start), + ) + + +def _block_sell_bid(bid_id: str = "block-1", indivisible: bool = False) -> BlockBid: + mar = Decimal("1.0") if indivisible else Decimal("0.5") + return BlockBid( + bid_id=bid_id, + bidding_zone=BiddingZone.AT, + direction=Direction.SELL, + delivery_period=_delivery_period(), + price=Decimal("45.00"), + volume=Decimal("100"), + min_acceptance_ratio=mar, + ) + + +def _block_buy_bid(bid_id: str = "block-buy-1") -> BlockBid: + return BlockBid( + bid_id=bid_id, + bidding_zone=BiddingZone.AT, + direction=Direction.BUY, + delivery_period=_delivery_period(), + price=Decimal("60.00"), + volume=Decimal("80"), + ) + + +def _product_id_resolver(mtu: MTUInterval) -> str: + """Return "hEXA{hour+1:02d}" for hourly, "qEXA{hour+1:02d}_{q}" for 15-min.""" + if mtu.duration == MTUDuration.HOURLY: + return f"hEXA{mtu.start.hour + 1:02d}" + quarter = mtu.start.minute // 15 + 1 + return f"qEXA{mtu.start.hour + 1:02d}_{quarter}" + + +def _block_product_resolver(period: DeliveryPeriod) -> str: + return "bEXAbase (01-24)" + + +# --------------------------------------------------------------------------- +# Tests: bidding_zone_to_control_area +# --------------------------------------------------------------------------- + + +class TestBiddingZoneToControlArea: + def test_at_maps_to_apg(self) -> None: + assert bidding_zone_to_control_area(BiddingZone.AT) == "APG" + + def test_de_lu_maps_to_amprion(self) -> None: + assert bidding_zone_to_control_area(BiddingZone.DE_LU) == "Amprion" + + def test_nl_maps_to_tennet(self) -> None: + assert bidding_zone_to_control_area(BiddingZone.NL) == "TenneT" + + def test_unsupported_nordic_zone_raises(self) -> None: + with pytest.raises(ValueError, match="not supported by EXAA"): + bidding_zone_to_control_area(BiddingZone.NO1) + + def test_unsupported_fr_raises(self) -> None: + with pytest.raises(ValueError, match="not supported by EXAA"): + bidding_zone_to_control_area(BiddingZone.FR) + + def test_unsupported_gb_raises(self) -> None: + with pytest.raises(ValueError, match="not supported by EXAA"): + bidding_zone_to_control_area(BiddingZone.GB) + + +# --------------------------------------------------------------------------- +# Tests: standard product ID helpers +# --------------------------------------------------------------------------- + + +class TestStandardProductIdHelpers: + def test_hourly_midnight_hour(self) -> None: + mtu = _mtu(datetime(2026, 4, 1, 0, 0, tzinfo=UTC)) + assert standard_hourly_product_id(mtu) == "hEXA01" + + def test_hourly_midday_hour(self) -> None: + mtu = _mtu(datetime(2026, 4, 1, 11, 0, tzinfo=UTC)) + assert standard_hourly_product_id(mtu) == "hEXA12" + + def test_hourly_last_hour(self) -> None: + mtu = _mtu(datetime(2026, 4, 1, 23, 0, tzinfo=UTC)) + assert standard_hourly_product_id(mtu) == "hEXA24" + + def test_quarter_hourly_first_quarter(self) -> None: + mtu = _mtu(datetime(2026, 4, 1, 0, 0, tzinfo=UTC), MTUDuration.QUARTER_HOURLY) + assert standard_quarter_hourly_product_id(mtu) == "qEXA01_1" + + def test_quarter_hourly_second_quarter(self) -> None: + mtu = _mtu(datetime(2026, 4, 1, 0, 15, tzinfo=UTC), MTUDuration.QUARTER_HOURLY) + assert standard_quarter_hourly_product_id(mtu) == "qEXA01_2" + + def test_quarter_hourly_fourth_quarter(self) -> None: + mtu = _mtu(datetime(2026, 4, 1, 0, 45, tzinfo=UTC), MTUDuration.QUARTER_HOURLY) + assert standard_quarter_hourly_product_id(mtu) == "qEXA01_4" + + def test_quarter_hourly_last_interval(self) -> None: + mtu = _mtu(datetime(2026, 4, 1, 23, 45, tzinfo=UTC), MTUDuration.QUARTER_HOURLY) + assert standard_quarter_hourly_product_id(mtu) == "qEXA24_4" + + +# --------------------------------------------------------------------------- +# Tests: simple_bid_to_exaa_product +# --------------------------------------------------------------------------- + + +class TestSimpleBidToExaaProduct: + def test_returns_exaa_product(self) -> None: + bid = _simple_sell_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + assert isinstance(result, ExaaProduct) + + def test_product_id_from_resolver(self) -> None: + bid = _simple_sell_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + assert result.product_id == "hEXA11" # T0 = hour 10, so hEXA11 + + def test_fill_or_kill_false_by_default(self) -> None: + bid = _simple_sell_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + assert result.fill_or_kill is False + + def test_fill_or_kill_can_be_set_true(self) -> None: + bid = _simple_sell_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver, fill_or_kill=True) + assert result.fill_or_kill is True + + def test_pair_count_matches_curve_steps(self) -> None: + bid = _simple_sell_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + assert len(result.price_volume_pairs) == len(bid.curve.steps) + + def test_sell_volumes_are_negative(self) -> None: + bid = _simple_sell_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + for pair in result.price_volume_pairs: + assert isinstance(pair.volume, float) + assert pair.volume < 0 + + def test_buy_volumes_are_positive(self) -> None: + bid = _simple_buy_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + for pair in result.price_volume_pairs: + assert pair.volume > 0 + + def test_price_values_converted_to_float(self) -> None: + bid = _simple_sell_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + for pair in result.price_volume_pairs: + assert isinstance(pair.price, float) + + def test_quarter_hourly_resolver_called_with_correct_mtu(self) -> None: + bid = _simple_sell_bid(duration=MTUDuration.QUARTER_HOURLY) + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + assert result.product_id.startswith("qEXA") + + +# --------------------------------------------------------------------------- +# Tests: block_bid_to_exaa_product +# --------------------------------------------------------------------------- + + +class TestBlockBidToExaaProduct: + def test_returns_exaa_product(self) -> None: + bid = _block_sell_bid() + result = block_bid_to_exaa_product(bid, "bEXAbase (01-24)") + assert isinstance(result, ExaaProduct) + + def test_product_id_passed_through(self) -> None: + bid = _block_sell_bid() + result = block_bid_to_exaa_product(bid, "bEXApeak (09-20)") + assert result.product_id == "bEXApeak (09-20)" + + def test_single_price_volume_pair(self) -> None: + bid = _block_sell_bid() + result = block_bid_to_exaa_product(bid, "bEXAbase (01-24)") + assert len(result.price_volume_pairs) == 1 + + def test_price_converted_to_float(self) -> None: + bid = _block_sell_bid() + result = block_bid_to_exaa_product(bid, "bEXAbase (01-24)") + assert isinstance(result.price_volume_pairs[0].price, float) + assert result.price_volume_pairs[0].price == pytest.approx(45.0) + + def test_sell_volume_is_negative(self) -> None: + bid = _block_sell_bid() + result = block_bid_to_exaa_product(bid, "bEXAbase (01-24)") + assert result.price_volume_pairs[0].volume == pytest.approx(-100.0) + + def test_buy_volume_is_positive(self) -> None: + bid = _block_buy_bid() + result = block_bid_to_exaa_product(bid, "bEXAbase (01-24)") + assert result.price_volume_pairs[0].volume == pytest.approx(80.0) + + def test_fill_or_kill_false_for_divisible_bid(self) -> None: + bid = _block_sell_bid(indivisible=False) + result = block_bid_to_exaa_product(bid, "bEXAbase (01-24)") + assert result.fill_or_kill is False + + def test_fill_or_kill_true_for_indivisible_bid(self) -> None: + bid = _block_sell_bid(indivisible=True) + result = block_bid_to_exaa_product(bid, "bEXAbase (01-24)") + assert result.fill_or_kill is True + + +# --------------------------------------------------------------------------- +# Tests: order_book_to_exaa +# --------------------------------------------------------------------------- + + +class TestOrderBookToExaa: + def test_returns_exaa_order_request(self) -> None: + ob = create_order_book() + ob = add_bid(ob, _simple_sell_bid()) + result = order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver) + assert isinstance(result, ExaaOrderRequest) + + def test_account_id_set_correctly(self) -> None: + ob = create_order_book() + ob = add_bid(ob, _simple_sell_bid()) + result = order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver) + assert result.orders[0].account_id == ACCOUNT_ID + + def test_hourly_simple_bids_go_to_hourly_container(self) -> None: + ob = create_order_book() + ob = add_bid(ob, _simple_sell_bid(duration=MTUDuration.HOURLY)) + result = order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver) + assert result.orders[0].hourly_products is not None + assert len(result.orders[0].hourly_products.products) == 1 + + def test_quarter_hourly_simple_bids_go_to_fifteen_min_container(self) -> None: + ob = create_order_book() + ob = add_bid(ob, _simple_sell_bid(duration=MTUDuration.QUARTER_HOURLY)) + result = order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver) + assert result.orders[0].fifteen_min_products is not None + assert len(result.orders[0].fifteen_min_products.products) == 1 + + def test_block_bids_go_to_block_container(self) -> None: + ob = create_order_book() + ob = add_bid(ob, _block_sell_bid()) + result = order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver, _block_product_resolver) + assert result.orders[0].block_products is not None + assert len(result.orders[0].block_products.products) == 1 + + def test_empty_containers_are_none(self) -> None: + ob = create_order_book() + ob = add_bid(ob, _simple_sell_bid(duration=MTUDuration.HOURLY)) + result = order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver) + assert result.orders[0].block_products is None + assert result.orders[0].fifteen_min_products is None + + def test_empty_order_book_returns_all_none_containers(self) -> None: + ob = create_order_book() + result = order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver) + assert result.orders[0].hourly_products is None + assert result.orders[0].block_products is None + assert result.orders[0].fifteen_min_products is None + + def test_order_type_applied_to_containers(self) -> None: + ob = create_order_book() + ob = add_bid(ob, _simple_sell_bid()) + result = order_book_to_exaa( + ob, ACCOUNT_ID, _product_id_resolver, order_type=ExaaOrderType.LINEAR + ) + assert result.orders[0].hourly_products is not None + assert result.orders[0].hourly_products.type_of_order == ExaaOrderType.LINEAR + + def test_default_order_type_is_step(self) -> None: + ob = create_order_book() + ob = add_bid(ob, _simple_sell_bid()) + result = order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver) + assert result.orders[0].hourly_products is not None + assert result.orders[0].hourly_products.type_of_order == ExaaOrderType.STEP + + def test_linked_block_bid_raises_value_error(self) -> None: + # OrderBook requires the parent to exist before adding a LinkedBlockBid. + parent = _block_sell_bid(bid_id="parent-1") + linked = LinkedBlockBid( + bid_id="linked-1", + parent_bid_id="parent-1", + bidding_zone=BiddingZone.AT, + direction=Direction.SELL, + delivery_period=_delivery_period(start=T0 + timedelta(hours=2)), + price=Decimal("40.00"), + volume=Decimal("50"), + ) + ob = create_order_book() + ob = add_bid(ob, parent) + ob = add_bid(ob, linked) + with pytest.raises(ValueError, match="LinkedBlockBid"): + order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver, _block_product_resolver) + + def test_exclusive_group_bid_raises_value_error(self) -> None: + ob = create_order_book() + bid_a = _block_sell_bid(bid_id="excl-a") + bid_b = _block_sell_bid(bid_id="excl-b") + grp = exclusive_group([bid_a, bid_b]) + ob = add_bid(ob, grp) + with pytest.raises(ValueError, match="ExclusiveGroupBid"): + order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver) + + def test_block_bids_without_resolver_raises_value_error(self) -> None: + ob = create_order_book() + ob = add_bid(ob, _block_sell_bid()) + with pytest.raises(ValueError, match="block_product_resolver"): + order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver) + + def test_multiple_bids_dispatched_to_correct_containers(self) -> None: + hourly_bid = SimpleBid( + bid_id="hourly-bid", + bidding_zone=BiddingZone.AT, + direction=Direction.SELL, + curve=_supply_curve(T0, MTUDuration.HOURLY), + ) + qh_bid = SimpleBid( + bid_id="qh-bid", + bidding_zone=BiddingZone.AT, + direction=Direction.SELL, + curve=_supply_curve(T0, MTUDuration.QUARTER_HOURLY), + ) + ob = create_order_book() + ob = add_bid(ob, hourly_bid) + ob = add_bid(ob, qh_bid) + ob = add_bid(ob, _block_sell_bid()) + result = order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver, _block_product_resolver) + assert result.orders[0].hourly_products is not None + assert len(result.orders[0].hourly_products.products) == 1 + assert result.orders[0].fifteen_min_products is not None + assert len(result.orders[0].fifteen_min_products.products) == 1 + assert result.orders[0].block_products is not None + assert len(result.orders[0].block_products.products) == 1 + + def test_units_always_eur_mwh(self) -> None: + ob = create_order_book() + result = order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver) + assert result.units.price == "EUR" + assert result.units.volume == "MWh/h" + + +# --------------------------------------------------------------------------- +# Tests: volume sign convention +# --------------------------------------------------------------------------- + + +class TestVolumeSignConvention: + """EXAA convention: BUY = positive, SELL = negative (opposite of Nord Pool).""" + + def test_sell_simple_bid_produces_negative_volumes(self) -> None: + bid = _simple_sell_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + for pair in result.price_volume_pairs: + assert pair.volume < 0, f"Expected negative volume for SELL, got {pair.volume}" + + def test_buy_simple_bid_produces_positive_volumes(self) -> None: + bid = _simple_buy_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + for pair in result.price_volume_pairs: + assert pair.volume > 0, f"Expected positive volume for BUY, got {pair.volume}" + + def test_sell_block_bid_produces_negative_volume(self) -> None: + bid = _block_sell_bid() + result = block_bid_to_exaa_product(bid, "bEXAbase (01-24)") + assert result.price_volume_pairs[0].volume < 0 + + def test_buy_block_bid_produces_positive_volume(self) -> None: + bid = _block_buy_bid() + result = block_bid_to_exaa_product(bid, "bEXAbase (01-24)") + assert result.price_volume_pairs[0].volume > 0 + + +# --------------------------------------------------------------------------- +# Tests: Decimal → float conversion +# --------------------------------------------------------------------------- + + +class TestDecimalToFloat: + def test_simple_bid_price_is_float(self) -> None: + bid = _simple_sell_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + for pair in result.price_volume_pairs: + assert isinstance(pair.price, float) + + def test_simple_bid_volume_is_float(self) -> None: + bid = _simple_sell_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + for pair in result.price_volume_pairs: + assert isinstance(pair.volume, float) + + def test_block_bid_price_is_float(self) -> None: + bid = _block_sell_bid() + result = block_bid_to_exaa_product(bid, "bEXAbase (01-24)") + assert isinstance(result.price_volume_pairs[0].price, float) + + def test_block_bid_volume_is_float(self) -> None: + bid = _block_sell_bid() + result = block_bid_to_exaa_product(bid, "bEXAbase (01-24)") + assert isinstance(result.price_volume_pairs[0].volume, float) + + def test_decimal_precision_preserved_in_conversion(self) -> None: + bid = _simple_sell_bid() + result = simple_bid_to_exaa_product(bid, _product_id_resolver) + prices = [p.price for p in result.price_volume_pairs] + assert prices[0] == pytest.approx(10.0) + assert prices[1] == pytest.approx(20.0) + + +# --------------------------------------------------------------------------- +# Tests: JSON serialisation (alias round-trip) +# --------------------------------------------------------------------------- + + +class TestJsonSerialisation: + def test_product_id_serialised_as_camel_case(self) -> None: + bid = _simple_sell_bid() + product = simple_bid_to_exaa_product(bid, _product_id_resolver) + dumped = product.model_dump(by_alias=True) + assert "productID" in dumped + assert "fillOrKill" in dumped + assert "priceVolumePairs" in dumped + + def test_fifteen_min_products_key_in_account_order(self) -> None: + ob = create_order_book() + ob = add_bid(ob, _simple_sell_bid(duration=MTUDuration.QUARTER_HOURLY)) + result = order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver) + dumped = result.orders[0].model_dump(by_alias=True) + assert "15minProducts" in dumped + + def test_account_id_serialised_as_camel_case(self) -> None: + ob = create_order_book() + result = order_book_to_exaa(ob, ACCOUNT_ID, _product_id_resolver) + dumped = result.orders[0].model_dump(by_alias=True) + assert "accountID" in dumped + assert dumped["accountID"] == ACCOUNT_ID + + def test_container_type_of_order_key(self) -> None: + container = ExaaProductTypeContainer.model_validate( + {"typeOfOrder": ExaaOrderType.STEP, "products": []} + ) + dumped = container.model_dump(by_alias=True) + assert "typeOfOrder" in dumped