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. diff --git a/src/frequenz/client/electricity_trading/cli/__main__.py b/src/frequenz/client/electricity_trading/cli/__main__.py index e19608ba..3228f77d 100644 --- a/src/frequenz/client/electricity_trading/cli/__main__.py +++ b/src/frequenz/client/electricity_trading/cli/__main__.py @@ -10,6 +10,12 @@ 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, +) from frequenz.client.electricity_trading.cli.etrading import ( list_orders as run_list_orders, ) @@ -54,6 +60,70 @@ 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("--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 f2db93b3..df1adf7f 100644 --- a/src/frequenz/client/electricity_trading/cli/etrading.py +++ b/src/frequenz/client/electricity_trading/cli/etrading.py @@ -3,13 +3,23 @@ """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, + Currency, + DeliveryArea, DeliveryPeriod, + EnergyMarketCodeType, + MarketSide, OrderDetail, + OrderType, + Power, + Price, PublicTrade, ) @@ -76,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. @@ -96,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): @@ -107,6 +120,83 @@ 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) + + +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 = ( @@ -202,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