|
| 1 | +# License: MIT |
| 2 | +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH |
| 3 | + |
| 4 | +"""System tests for Electricity Trading API.""" |
| 5 | +import asyncio |
| 6 | +from datetime import datetime, timedelta, timezone |
| 7 | +from decimal import Decimal |
| 8 | +from typing import Any, Generator |
| 9 | + |
| 10 | +import os |
| 11 | +import grpc |
| 12 | +import pytest |
| 13 | + |
| 14 | +from frequenz.client.electricity_trading import ( |
| 15 | + Client, |
| 16 | + Currency, |
| 17 | + DeliveryArea, |
| 18 | + DeliveryPeriod, |
| 19 | + EnergyMarketCodeType, |
| 20 | + MarketSide, |
| 21 | + OrderDetail, |
| 22 | + OrderState, |
| 23 | + OrderType, |
| 24 | + Power, |
| 25 | + Price, |
| 26 | +) |
| 27 | + |
| 28 | +API_KEY = os.getenv("API_KEY", None) |
| 29 | +if not API_KEY: |
| 30 | + raise ValueError("API Key is not set") |
| 31 | +GRIDPOOL_ID = os.getenv("GRIDPOOL_ID", None) |
| 32 | +if not GRIDPOOL_ID: |
| 33 | + raise ValueError("Gridpool ID is not set") |
| 34 | +GRIDPOOL_ID = int(GRIDPOOL_ID) |
| 35 | +SERVER_URL = "grpc://electricity-trading-testing.api.frequenz.com:443?ssl=true" |
| 36 | + |
| 37 | + |
| 38 | +@pytest.fixture |
| 39 | +async def set_up() -> dict[str, Any]: |
| 40 | + """Set up the test suite.""" |
| 41 | + client = Client( |
| 42 | + server_url=SERVER_URL, |
| 43 | + auth_key=API_KEY, |
| 44 | + ) |
| 45 | + |
| 46 | + delivery_area = DeliveryArea( |
| 47 | + code="10YDE-EON------1", code_type=EnergyMarketCodeType.EUROPE_EIC |
| 48 | + ) |
| 49 | + # Setting delivery start to the next whole hour after two hours from now |
| 50 | + delivery_start = (datetime.now(timezone.utc) + timedelta(hours=3)).replace( |
| 51 | + minute=0, second=0, microsecond=0 |
| 52 | + ) |
| 53 | + delivery_period = DeliveryPeriod( |
| 54 | + start=delivery_start, |
| 55 | + duration=timedelta(minutes=15), |
| 56 | + ) |
| 57 | + price = Price(amount=Decimal("56"), currency=Currency.EUR) |
| 58 | + quantity = Power(mw=Decimal("0.1")) |
| 59 | + order_type = OrderType.LIMIT |
| 60 | + |
| 61 | + return { |
| 62 | + "client": client, |
| 63 | + "delivery_area": delivery_area, |
| 64 | + "delivery_period": delivery_period, |
| 65 | + "price": price, |
| 66 | + "quantity": quantity, |
| 67 | + "order_type": order_type, |
| 68 | + } |
| 69 | + |
| 70 | + |
| 71 | +async def create_test_order( |
| 72 | + set_up: dict[str, Any], |
| 73 | + side: MarketSide = MarketSide.BUY, |
| 74 | + price: Price | None = None, |
| 75 | + delivery_period: DeliveryPeriod | None = None, |
| 76 | +) -> OrderDetail: |
| 77 | + """Create a test order with customizable parameters.""" |
| 78 | + order_price = price or set_up["price"] |
| 79 | + order_delivery_period = delivery_period or set_up["delivery_period"] |
| 80 | + order = await set_up["client"].create_gridpool_order( |
| 81 | + gridpool_id=GRIDPOOL_ID, |
| 82 | + delivery_area=set_up["delivery_area"], |
| 83 | + delivery_period=order_delivery_period, |
| 84 | + order_type=set_up["order_type"], |
| 85 | + side=side, |
| 86 | + price=order_price, |
| 87 | + quantity=set_up["quantity"], |
| 88 | + tag="api-integration-test", |
| 89 | + ) |
| 90 | + return order # type: ignore |
| 91 | + |
| 92 | + |
| 93 | +async def create_test_trade( |
| 94 | + set_up: dict[str, Any], |
| 95 | +) -> tuple[OrderDetail, OrderDetail]: |
| 96 | + """ |
| 97 | + Create identical orders on opposite sides to try to trigger a trade. |
| 98 | +
|
| 99 | + Args: |
| 100 | + set_up: The setup dictionary. |
| 101 | + Returns: |
| 102 | + A tuple of the created buy and sell orders. |
| 103 | + """ |
| 104 | + # Set a different delivery period so that it is the only trade retrieved |
| 105 | + # It should also be < 9 hours from now since EPEX's intraday order book opens at 15:00 |
| 106 | + delivery_start = (datetime.now(timezone.utc) + timedelta(hours=2)).replace( |
| 107 | + minute=0, second=0, microsecond=0 |
| 108 | + ) |
| 109 | + delivery_period = DeliveryPeriod( |
| 110 | + start=delivery_start, |
| 111 | + duration=timedelta(minutes=15), |
| 112 | + ) |
| 113 | + buy_order = await create_test_order( |
| 114 | + set_up=set_up, |
| 115 | + delivery_period=delivery_period, |
| 116 | + side=MarketSide.BUY, |
| 117 | + price=Price(amount=Decimal("33"), currency=Currency.EUR), |
| 118 | + ) |
| 119 | + |
| 120 | + sell_order = await create_test_order( |
| 121 | + set_up=set_up, |
| 122 | + delivery_period=delivery_period, |
| 123 | + side=MarketSide.SELL, |
| 124 | + price=Price(amount=Decimal("33"), currency=Currency.EUR), |
| 125 | + ) |
| 126 | + |
| 127 | + return buy_order, sell_order |
| 128 | + |
| 129 | + |
| 130 | +@pytest.mark.asyncio |
| 131 | +async def test_create_and_get_order(set_up: dict[str, Any]) -> None: |
| 132 | + """Test creating a gridpool order and ensure it exists in the system.""" |
| 133 | + # Create an order first |
| 134 | + order = await create_test_order(set_up) |
| 135 | + assert order is not None, "Order creation failed" |
| 136 | + |
| 137 | + # Fetch order to check it exists remotely |
| 138 | + fetched_order = await set_up["client"].get_gridpool_order( |
| 139 | + GRIDPOOL_ID, order.order_id |
| 140 | + ) |
| 141 | + |
| 142 | + assert fetched_order.order == order.order, "Order mismatch" |
| 143 | + |
| 144 | + |
| 145 | +@pytest.mark.asyncio |
| 146 | +async def test_create_order_invalid_delivery_start_one_day_ago( |
| 147 | + set_up: dict[str, Any] |
| 148 | +) -> None: |
| 149 | + """Test creating an order with a passed delivery start (one day ago).""" |
| 150 | + # Create an order with a delivery start in the past |
| 151 | + delivery_start = (datetime.now(timezone.utc) - timedelta(days=1)).replace( |
| 152 | + minute=0, second=0, microsecond=0 |
| 153 | + ) |
| 154 | + delivery_period = DeliveryPeriod( |
| 155 | + start=delivery_start, |
| 156 | + duration=timedelta(minutes=15), |
| 157 | + ) |
| 158 | + with pytest.raises(ValueError, match="delivery_period must be in the future"): |
| 159 | + await create_test_order(set_up, delivery_period=delivery_period) |
| 160 | + |
| 161 | + |
| 162 | +@pytest.mark.asyncio |
| 163 | +async def test_create_order_invalid_delivery_start_one_hour_ago( |
| 164 | + set_up: dict[str, Any] |
| 165 | +) -> None: |
| 166 | + """Test creating an order with a passed delivery start (one hour ago).""" |
| 167 | + # Create an order with a delivery start in the past |
| 168 | + delivery_start = (datetime.now(timezone.utc) - timedelta(hours=1)).replace( |
| 169 | + minute=0, second=0, microsecond=0 |
| 170 | + ) |
| 171 | + delivery_period = DeliveryPeriod( |
| 172 | + start=delivery_start, |
| 173 | + duration=timedelta(minutes=15), |
| 174 | + ) |
| 175 | + with pytest.raises(ValueError, match="delivery_period must be in the future"): |
| 176 | + await create_test_order(set_up, delivery_period=delivery_period) |
| 177 | + |
| 178 | + |
| 179 | +@pytest.mark.asyncio |
| 180 | +async def test_create_order_invalid_delivery_start_15_minutes_ago( |
| 181 | + set_up: dict[str, Any] |
| 182 | +) -> None: |
| 183 | + """Test creating an order with a passed delivery start (15 minutes ago).""" |
| 184 | + # Create an order with a delivery start in the past |
| 185 | + delivery_start = (datetime.now(timezone.utc) - timedelta(minutes=15)).replace( |
| 186 | + minute=0, second=0, microsecond=0 |
| 187 | + ) |
| 188 | + delivery_period = DeliveryPeriod( |
| 189 | + start=delivery_start, |
| 190 | + duration=timedelta(minutes=15), |
| 191 | + ) |
| 192 | + with pytest.raises(ValueError, match="delivery_period must be in the future"): |
| 193 | + await create_test_order(set_up, delivery_period=delivery_period) |
| 194 | + |
| 195 | + |
| 196 | +@pytest.mark.asyncio |
| 197 | +async def test_list_gridpool_orders(set_up: dict[str, Any]) -> None: |
| 198 | + """Test listing gridpool orders and ensure they exist in the system.""" |
| 199 | + # Create several orders |
| 200 | + created_orders_id = [(await create_test_order(set_up)).order_id for _ in range(10)] |
| 201 | + |
| 202 | + # List the orders and check they are present |
| 203 | + orders = await set_up["client"].list_gridpool_orders( |
| 204 | + gridpool_id=GRIDPOOL_ID, delivery_period=set_up["delivery_period"] |
| 205 | + ) # filter by delivery period to avoid fetching too many orders |
| 206 | + |
| 207 | + listed_orders_id = [order.order_id for order in orders] |
| 208 | + for order_id in created_orders_id: |
| 209 | + assert order_id in listed_orders_id, f"Order ID {order_id} not found" |
| 210 | + |
| 211 | + |
| 212 | +@pytest.mark.asyncio |
| 213 | +async def test_update_order_price(set_up: dict[str, Any]) -> None: |
| 214 | + """Test updating the price of an order.""" |
| 215 | + # Create an order first |
| 216 | + order = await create_test_order(set_up) |
| 217 | + |
| 218 | + # Update the order price and check the update was successful |
| 219 | + new_price = Price(amount=Decimal("50"), currency=Currency.EUR) |
| 220 | + updated_order = await set_up["client"].update_gridpool_order( |
| 221 | + gridpool_id=GRIDPOOL_ID, order_id=order.order_id, price=new_price |
| 222 | + ) |
| 223 | + |
| 224 | + assert updated_order.order.price.amount == new_price.amount, "Price update failed" |
| 225 | + fetched_order = await set_up["client"].get_gridpool_order( |
| 226 | + GRIDPOOL_ID, order.order_id |
| 227 | + ) |
| 228 | + assert ( |
| 229 | + fetched_order.order.price.amount == updated_order.order.price.amount |
| 230 | + ), "Fetched price mismatch after update" |
| 231 | + assert ( |
| 232 | + order.order.price.amount != new_price.amount |
| 233 | + ), "Original price should not be the same as the updated price" |
| 234 | + |
| 235 | + |
| 236 | +@pytest.mark.asyncio |
| 237 | +async def test_update_order_quantity_failure(set_up: dict[str, Any]) -> None: |
| 238 | + """Test updating the quantity of an order and ensure it fails.""" |
| 239 | + # Create an order first |
| 240 | + order = await create_test_order(set_up) |
| 241 | + |
| 242 | + quantity = Power(mw=Decimal("10")) |
| 243 | + |
| 244 | + # Expected failure as quantity update is not supported |
| 245 | + with pytest.raises(grpc.aio.AioRpcError) as excinfo: |
| 246 | + await set_up["client"].update_gridpool_order( |
| 247 | + gridpool_id=GRIDPOOL_ID, order_id=order.order_id, quantity=quantity |
| 248 | + ) |
| 249 | + |
| 250 | + assert str(excinfo.value.details()) == "Updating 'quantity' is not allowed." |
| 251 | + assert ( |
| 252 | + excinfo.value.code() == grpc.StatusCode.INVALID_ARGUMENT |
| 253 | + ), "Expected INVALID_ARGUMENT error" |
| 254 | + |
| 255 | + |
| 256 | +@pytest.mark.asyncio |
| 257 | +async def test_cancel_order(set_up: dict[str, Any]) -> None: |
| 258 | + """Test cancelling an order.""" |
| 259 | + # Create the order to be cancelled |
| 260 | + order = await create_test_order(set_up) |
| 261 | + |
| 262 | + # Cancel the created order and ensure it's cancelled |
| 263 | + cancelled_order = await set_up["client"].cancel_gridpool_order( |
| 264 | + GRIDPOOL_ID, order.order_id |
| 265 | + ) |
| 266 | + assert cancelled_order.order_id == order.order_id, "Order cancellation failed" |
| 267 | + |
| 268 | + fetched_order = await set_up["client"].get_gridpool_order( |
| 269 | + GRIDPOOL_ID, order.order_id |
| 270 | + ) |
| 271 | + assert ( |
| 272 | + fetched_order.state_detail.state == OrderState.CANCELED |
| 273 | + ), "Order state should be CANCELED" |
| 274 | + |
| 275 | + |
| 276 | +@pytest.mark.asyncio |
| 277 | +async def test_update_cancelled_order_failure(set_up: dict[str, Any]) -> None: |
| 278 | + """Test updating a cancelled order and ensure it fails.""" |
| 279 | + # Create an order first |
| 280 | + order = await create_test_order(set_up) |
| 281 | + |
| 282 | + # Cancel the created order |
| 283 | + await set_up["client"].cancel_gridpool_order(GRIDPOOL_ID, order.order_id) |
| 284 | + |
| 285 | + # Expected failure as cancelled order cannot be updated |
| 286 | + with pytest.raises(grpc.aio.AioRpcError) as excinfo: |
| 287 | + await set_up["client"].update_gridpool_order( |
| 288 | + gridpool_id=GRIDPOOL_ID, order_id=order.order_id, price=set_up["price"] |
| 289 | + ) |
| 290 | + assert ( |
| 291 | + excinfo.value.code() == grpc.StatusCode.INVALID_ARGUMENT |
| 292 | + ), "Expected INVALID_ARGUMENT error" |
| 293 | + |
| 294 | + |
| 295 | +@pytest.mark.asyncio |
| 296 | +async def test_cancel_all_orders(set_up: dict[str, Any]) -> None: |
| 297 | + """Test cancelling all orders.""" |
| 298 | + # Create multiple orders |
| 299 | + for _ in range(10): |
| 300 | + await create_test_order(set_up) |
| 301 | + |
| 302 | + # Cancel all orders and check that did indeed get cancelled |
| 303 | + await set_up["client"].cancel_all_gridpool_orders(GRIDPOOL_ID) |
| 304 | + |
| 305 | + orders = await set_up["client"].list_gridpool_orders(gridpool_id=GRIDPOOL_ID) |
| 306 | + |
| 307 | + for order in orders: |
| 308 | + assert ( |
| 309 | + order.state_detail.state == OrderState.CANCELED |
| 310 | + ), f"Order {order.order_id} not canceled" |
| 311 | + |
| 312 | + |
| 313 | +@pytest.mark.asyncio |
| 314 | +async def test_list_gridpool_trades(set_up: dict[str, Any]) -> None: |
| 315 | + """Test listing gridpool trades.""" |
| 316 | + buy_order, sell_order = await create_test_trade(set_up) |
| 317 | + trades = await set_up["client"].list_gridpool_trades( |
| 318 | + GRIDPOOL_ID, |
| 319 | + delivery_period=buy_order.order.delivery_period, |
| 320 | + ) |
| 321 | + assert len(trades) >= 1 |
| 322 | + |
| 323 | + |
| 324 | +@pytest.mark.asyncio |
| 325 | +async def test_list_public_trades(set_up: dict[str, Any]) -> None: |
| 326 | + """Test listing public trades.""" |
| 327 | + public_trades = await set_up["client"].list_public_trades( |
| 328 | + delivery_period=set_up["delivery_period"], |
| 329 | + max_nr_trades=10, |
| 330 | + ) |
| 331 | + assert len(public_trades) >= 0 |
| 332 | + |
| 333 | + |
| 334 | +@pytest.mark.asyncio |
| 335 | +async def test_stream_gridpool_orders(set_up: dict[str, Any]) -> None: |
| 336 | + """Test streaming gridpool orders.""" |
| 337 | + stream = await set_up["client"].stream_gridpool_orders(GRIDPOOL_ID) |
| 338 | + test_order = await create_test_order(set_up) |
| 339 | + |
| 340 | + try: |
| 341 | + # Stream trades with a 15-second timeout to avoid indefinite hanging |
| 342 | + streamed_order = await asyncio.wait_for(anext(stream), timeout=15) |
| 343 | + assert streamed_order is not None, "Failed to receive streamed order." |
| 344 | + assert ( |
| 345 | + streamed_order.order == test_order.order |
| 346 | + ), "Streamed order does not match created order" |
| 347 | + except asyncio.TimeoutError: |
| 348 | + pytest.fail("Streaming timed out, no order received in 15 seconds") |
| 349 | + |
| 350 | + |
| 351 | +@pytest.mark.asyncio |
| 352 | +async def test_stream_public_trades(set_up: dict[str, Any]) -> None: |
| 353 | + """Test stream public trades.""" |
| 354 | + stream = await set_up["client"].stream_public_trades() |
| 355 | + |
| 356 | + try: |
| 357 | + # Stream trades with a 15-second timeout to avoid indefinite hanging |
| 358 | + streamed_trade = await asyncio.wait_for(anext(stream), timeout=15) |
| 359 | + assert streamed_trade is not None, "Failed to receive streamed trade" |
| 360 | + except asyncio.TimeoutError: |
| 361 | + pytest.fail("Streaming timed out, no trade received in 15 seconds") |
| 362 | + |
| 363 | + |
| 364 | +@pytest.mark.asyncio |
| 365 | +async def test_stream_gridpool_trades(set_up: dict[str, Any]) -> None: |
| 366 | + """Test stream gridpool trades.""" |
| 367 | + stream = await set_up["client"].stream_gridpool_trades(GRIDPOOL_ID) |
| 368 | + |
| 369 | + # Create identical orders on opposite sides to try to trigger a trade |
| 370 | + await create_test_trade(set_up) |
| 371 | + |
| 372 | + try: |
| 373 | + # Stream trades with a 15-second timeout to avoid indefinite hanging |
| 374 | + streamed_trade = await asyncio.wait_for(anext(stream), timeout=15) |
| 375 | + assert streamed_trade is not None, "Failed to receive streamed trade" |
| 376 | + except asyncio.TimeoutError: |
| 377 | + pytest.fail("Streaming timed out, no trade received in 15 seconds") |
| 378 | + |
| 379 | + |
| 380 | +@pytest.fixture(scope="session") |
| 381 | +def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: |
| 382 | + """Create an event loop for the tests.""" |
| 383 | + loop = asyncio.get_event_loop_policy().new_event_loop() |
| 384 | + yield loop |
| 385 | + loop.close() |
0 commit comments