From f3c01aa961ae8e2d81bb9665bde81d8177c876ad Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Tue, 14 Jan 2025 09:44:28 +0100 Subject: [PATCH 1/4] Add CLI command to create limit orders The command allows creating a limit order on a testing instance, which is identified by containing the string "test" in its URL. The market side is derived from the sign of the quantity. For now only delivery area codes in EUROPE_EIC format are supported. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- .../electricity_trading/cli/__main__.py | 48 ++++++++++++++ .../electricity_trading/cli/etrading.py | 65 +++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/src/frequenz/client/electricity_trading/cli/__main__.py b/src/frequenz/client/electricity_trading/cli/__main__.py index e19608ba..ac68b934 100644 --- a/src/frequenz/client/electricity_trading/cli/__main__.py +++ b/src/frequenz/client/electricity_trading/cli/__main__.py @@ -10,6 +10,9 @@ import click from frequenz.client.electricity_trading.cli.day_ahead import list_day_ahead_prices +from frequenz.client.electricity_trading.cli.etrading import ( + create_order as run_create_order, +) from frequenz.client.electricity_trading.cli.etrading import ( list_orders as run_list_orders, ) @@ -54,6 +57,51 @@ def list_orders(url: str, key: str, *, start: datetime, gid: int) -> None: asyncio.run(run_list_orders(url=url, key=key, delivery_start=start, gid=gid)) +@cli.command() +@click.option("--url", required=True, type=str) +@click.option("--key", required=True, type=str) +@click.option("--start", required=True, type=iso) +@click.option("--gid", required=True, type=int) +@click.option("--quantity", required=True, type=str) +@click.option("--price", required=True, type=str) +@click.option("--area", required=True, type=str) +@click.option("--currency", default="EUR", type=str) +@click.option("--duration", default=900, type=int) +def create_order( + # pylint: disable=too-many-arguments + url: str, + key: str, + *, + start: datetime, + gid: int, + quantity: str, + price: str, + area: str, + currency: str, + duration: int, +) -> None: + """Create an order. + + This is only allowed in test instances. + """ + if "test" not in url: + raise ValueError("Creating orders is only allowed in test instances.") + + asyncio.run( + run_create_order( + url=url, + key=key, + delivery_start=start, + gid=gid, + quantity_mw=quantity, + price=price, + delivery_area=area, + currency=currency, + duration=timedelta(seconds=duration), + ) + ) + + @cli.command() @click.option("--entsoe-key", required=True, type=str) @click.option("--start", default=midnight(), type=iso) diff --git a/src/frequenz/client/electricity_trading/cli/etrading.py b/src/frequenz/client/electricity_trading/cli/etrading.py index f2db93b3..c53c7489 100644 --- a/src/frequenz/client/electricity_trading/cli/etrading.py +++ b/src/frequenz/client/electricity_trading/cli/etrading.py @@ -4,12 +4,20 @@ """CLI tool to interact with the trading API.""" from datetime import datetime, timedelta, timezone +from decimal import Decimal from enum import Enum from frequenz.client.electricity_trading import ( Client, + Currency, + DeliveryArea, DeliveryPeriod, + EnergyMarketCodeType, + MarketSide, OrderDetail, + OrderType, + Power, + Price, PublicTrade, ) @@ -107,6 +115,63 @@ async def list_orders( print_order(order) +# pylint: disable=too-many-arguments +async def create_order( + url: str, + key: str, + *, + gid: int, + delivery_start: datetime, + delivery_area: str, + price: str, + quantity_mw: str, + currency: str, + duration: timedelta, +) -> None: + """Create a limit order for a given price and quantity (in MW). + + The market side is determined by the sign of the quantity, positive for buy orders + and negative for sell orders. The delivery area code is expected to be in + EUROPE_EIC format. + + Args: + url: URL of the trading API. + key: API key. + gid: Gridpool ID. + delivery_start: Start of the delivery period. + delivery_area: Delivery area code. + price: Price of the order. + quantity_mw: Quantity in MW, positive for buy orders and negative for sell orders. + currency: Currency of the price. + duration: Duration of the delivery period. + """ + client = Client(server_url=url, auth_key=key) + + side = MarketSide.SELL if quantity_mw[0] == "-" else MarketSide.BUY + quantity = Power(mw=Decimal(quantity_mw.lstrip("-"))) + check_delivery_start(delivery_start) + order = await client.create_gridpool_order( + gridpool_id=gid, + delivery_area=DeliveryArea( + code=delivery_area, + code_type=EnergyMarketCodeType.EUROPE_EIC, + ), + delivery_period=DeliveryPeriod( + start=delivery_start, + duration=duration, + ), + order_type=OrderType.LIMIT, + side=side, + price=Price( + amount=Decimal(price), + currency=Currency[currency], + ), + quantity=quantity, + ) + + print_order(order) + + def print_trade_header() -> None: """Print trade header in CSV format.""" header = ( From 4859e25b7e301424699f1ba0965eade4484cf6f3 Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Tue, 14 Jan 2025 19:08:10 +0100 Subject: [PATCH 2/4] Add CLI commands to cancel gridpool orders Commands are added to cancel an individual order by its ID or all orders for a particular gridpool. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- .../electricity_trading/cli/__main__.py | 22 +++++++++++++++++++ .../electricity_trading/cli/etrading.py | 20 +++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/frequenz/client/electricity_trading/cli/__main__.py b/src/frequenz/client/electricity_trading/cli/__main__.py index ac68b934..3228f77d 100644 --- a/src/frequenz/client/electricity_trading/cli/__main__.py +++ b/src/frequenz/client/electricity_trading/cli/__main__.py @@ -10,6 +10,9 @@ import click from frequenz.client.electricity_trading.cli.day_ahead import list_day_ahead_prices +from frequenz.client.electricity_trading.cli.etrading import ( + cancel_order as run_cancel_order, +) from frequenz.client.electricity_trading.cli.etrading import ( create_order as run_create_order, ) @@ -102,6 +105,25 @@ def create_order( ) +@cli.command() +@click.option("--url", required=True, type=str) +@click.option("--key", required=True, type=str) +@click.option("--gid", required=True, type=int) +@click.option("--order", required=True, type=int) +def cancel_order(url: str, key: str, gid: int, order: int) -> None: + """Cancel an order.""" + asyncio.run(run_cancel_order(url=url, key=key, gridpool_id=gid, order_id=order)) + + +@cli.command() +@click.option("--url", required=True, type=str) +@click.option("--key", required=True, type=str) +@click.option("--gid", required=True, type=int) +def cancel_all_orders(url: str, key: str, gid: int) -> None: + """Cancel all orders for a gridpool ID.""" + asyncio.run(run_cancel_order(url=url, key=key, gridpool_id=gid, order_id=None)) + + @cli.command() @click.option("--entsoe-key", required=True, type=str) @click.option("--start", default=midnight(), type=iso) diff --git a/src/frequenz/client/electricity_trading/cli/etrading.py b/src/frequenz/client/electricity_trading/cli/etrading.py index c53c7489..ebd6e9ec 100644 --- a/src/frequenz/client/electricity_trading/cli/etrading.py +++ b/src/frequenz/client/electricity_trading/cli/etrading.py @@ -172,6 +172,26 @@ async def create_order( print_order(order) +async def cancel_order( + url: str, key: str, *, gridpool_id: int, order_id: int | None +) -> None: + """Cancel an order by order ID. + + If order_id is None, cancel all orders in the gridpool. + + Args: + url: URL of the trading API. + key: API key. + gridpool_id: Gridpool ID. + order_id: Order ID to cancel or None to cancel all orders. + """ + client = Client(server_url=url, auth_key=key) + if order_id is None: + await client.cancel_all_gridpool_orders(gridpool_id) + else: + await client.cancel_gridpool_order(gridpool_id, order_id) + + def print_trade_header() -> None: """Print trade header in CSV format.""" header = ( From d431265834a23d1dc1b01cdf59d48e2c7fb406fd Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Tue, 14 Jan 2025 21:47:11 +0100 Subject: [PATCH 3/4] Reverse sort order of listed orders On listing orders the API returns newest orders first. To print newest orders at the bottom of the list the sort order is reversed. To keep memory usage within limits the reversal is only performed within chunks. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- .../electricity_trading/cli/etrading.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/frequenz/client/electricity_trading/cli/etrading.py b/src/frequenz/client/electricity_trading/cli/etrading.py index ebd6e9ec..df1adf7f 100644 --- a/src/frequenz/client/electricity_trading/cli/etrading.py +++ b/src/frequenz/client/electricity_trading/cli/etrading.py @@ -3,9 +3,11 @@ """CLI tool to interact with the trading API.""" +from collections import deque from datetime import datetime, timedelta, timezone from decimal import Decimal from enum import Enum +from typing import AsyncIterator from frequenz.client.electricity_trading import ( Client, @@ -84,6 +86,9 @@ async def list_orders( for the 15 minute delivery period starting at delivery_start. If no delivery_start is provided, stream new orders for any delivery period. + Note that retrieved sort order for listed orders (starting from the newest) + is reversed in chunks trying to bring more recent orders to the bottom. + Args: url: URL of the trading API. key: API key. @@ -104,7 +109,7 @@ async def list_orders( ) lst = client.list_gridpool_orders(gid, delivery_period=delivery_period) - async for order in lst: + async for order in reverse_iterator(lst): print_order(order) if delivery_start and delivery_start <= datetime.now(timezone.utc): @@ -287,3 +292,27 @@ def print_order(order: OrderDetail) -> None: order.state_detail.state, ] print(",".join(v.name if isinstance(v, Enum) else str(v) for v in values)) + + +async def reverse_iterator( + iterator: AsyncIterator[OrderDetail], chunk_size: int = 100_000 +) -> AsyncIterator[OrderDetail]: + """Reverse an async iterator in chunks to avoid loading all elements into memory. + + Args: + iterator: Async iterator to reverse. + chunk_size: Size of the buffer to store elements. + + Yields: + Elements of the iterator in reverse order. + """ + buffer: deque[OrderDetail] = deque(maxlen=chunk_size) + async for item in iterator: + buffer.append(item) + if len(buffer) == chunk_size: + for item in reversed(buffer): + yield item + buffer.clear() + if buffer: + for item in reversed(buffer): + yield item From 5f90e5e25654ee782ea8c9f8b4af9ddf7745367b Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:49:29 +0100 Subject: [PATCH 4/4] Update release notes Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- RELEASE_NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3b5325e0..0730b0c0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -14,6 +14,9 @@ * `list-day-ahead`: Listing day-ahead prices from the entsoe API. * `list-trades`: Listing and streaming public trades for specified delivery periods. If no delivery start is given, starts streaming all new public trades. * `list-orders`: Listing and streaming orders for specified delivery periods and gridpool IDs. If no delivery start is given, starts streaming all new orders for this gridpool ID. + * `create-order`: Creating a limit order for a given price (in EUR/MWh) and quantity (in MW, sign determines market side). + * `cancel-order`: Cancel individual orders for a gridpool. + * `cancel-all-orders`: Cancels all orders of a gridpool.