diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 176fb35a..3b5325e0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,7 +10,11 @@ ## New Features -* Add trading-cli tool to interact with the trading API. This initial version only provides a tool to list day-ahead prices from the entsoe API. +* Add trading-cli tool to interact with the trading API. Supports the following commands: + * `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. + diff --git a/pyproject.toml b/pyproject.toml index 4cc9a315..e82e7813 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ requires-python = ">= 3.11, < 4" # TODO(cookiecutter): Remove and add more dependencies if appropriate dependencies = [ + "click >= 8.1.8, < 9", "entsoe-py >= 0.6.16, < 1", "frequenz-api-common >= 0.6.3, < 0.7.0", "grpcio >= 1.66.2, < 2", diff --git a/src/frequenz/client/electricity_trading/cli/__main__.py b/src/frequenz/client/electricity_trading/cli/__main__.py index 9fa1aff0..e19608ba 100644 --- a/src/frequenz/client/electricity_trading/cli/__main__.py +++ b/src/frequenz/client/electricity_trading/cli/__main__.py @@ -3,41 +3,75 @@ """CLI tool to interact with the trading API.""" -import argparse +import asyncio from datetime import datetime, timedelta from zoneinfo import ZoneInfo +import click + from frequenz.client.electricity_trading.cli.day_ahead import list_day_ahead_prices +from frequenz.client.electricity_trading.cli.etrading import ( + list_orders as run_list_orders, +) +from frequenz.client.electricity_trading.cli.etrading import ( + list_trades as run_list_trades, +) +TZ = ZoneInfo("Europe/Berlin") -def main() -> None: - """Run main entry point for the CLI tool.""" - tz = ZoneInfo("Europe/Berlin") - midnight = datetime.combine(datetime.now(tz), datetime.min.time(), tzinfo=tz) - parser = argparse.ArgumentParser() - parser.add_argument("--entsoe_key", type=str, required=True) - parser.add_argument( - "--start", - type=datetime.fromisoformat, - required=False, - default=midnight, - ) - parser.add_argument( - "--end", - type=datetime.fromisoformat, - required=False, - default=midnight + timedelta(days=2), - ) - parser.add_argument("--country_code", type=str, required=False, default="DE_LU") - args = parser.parse_args() +iso = datetime.fromisoformat + + +def midnight(days: int = 0) -> str: + """Return today's midnight.""" + return ( + datetime.combine(datetime.now(TZ), datetime.min.time(), tzinfo=TZ) + + timedelta(days) + ).isoformat() + + +@click.group() +def cli() -> None: + """CLI tool to interact with the trading API.""" + +@cli.command() +@click.option("--url", required=True, type=str) +@click.option("--key", required=True, type=str) +@click.option("--start", default=None, type=iso) +def list_trades(url: str, key: str, *, start: datetime) -> None: + """List trades.""" + asyncio.run(run_list_trades(url=url, key=key, delivery_start=start)) + + +@cli.command() +@click.option("--url", required=True, type=str) +@click.option("--key", required=True, type=str) +@click.option("--start", default=None, type=iso) +@click.option("--gid", required=True, type=int) +def list_orders(url: str, key: str, *, start: datetime, gid: int) -> None: + """List orders.""" + asyncio.run(run_list_orders(url=url, key=key, delivery_start=start, gid=gid)) + + +@cli.command() +@click.option("--entsoe-key", required=True, type=str) +@click.option("--start", default=midnight(), type=iso) +@click.option("--end", default=midnight(days=2), type=iso) +@click.option("--country-code", type=str, default="DE_LU") +def list_day_ahead( + entsoe_key: str, *, start: datetime, end: datetime, country_code: str +) -> None: + """List day-ahead prices.""" list_day_ahead_prices( - entsoe_key=args.entsoe_key, - start=args.start, - end=args.end, - country_code=args.country_code, + entsoe_key=entsoe_key, start=start, end=end, country_code=country_code ) +def main() -> None: + """Run the main Click CLI.""" + cli() + + if __name__ == "__main__": main() diff --git a/src/frequenz/client/electricity_trading/cli/etrading.py b/src/frequenz/client/electricity_trading/cli/etrading.py new file mode 100644 index 00000000..f2db93b3 --- /dev/null +++ b/src/frequenz/client/electricity_trading/cli/etrading.py @@ -0,0 +1,204 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""CLI tool to interact with the trading API.""" + +from datetime import datetime, timedelta, timezone +from enum import Enum + +from frequenz.client.electricity_trading import ( + Client, + DeliveryPeriod, + OrderDetail, + PublicTrade, +) + + +def check_delivery_start( + ts: datetime, duration: timedelta = timedelta(minutes=15) +) -> None: + """Validate that the delivery start is a multiple of duration. + + Args: + ts: Delivery start timestamp. + duration: Delivery period duration. + + Raises: + ValueError: If `ts` is not a multiple of `duration`. + """ + if int(ts.timestamp()) % int(duration.total_seconds()) != 0: + raise ValueError("Delivery period must be a multiple of `duration`.") + + +async def list_trades(url: str, key: str, *, delivery_start: datetime) -> None: + """List trades and stream new trades. + + If delivery_start is provided, list historical trades and stream new trades + for the 15 minute delivery period starting at delivery_start. + If no delivery_start is provided, stream new trades for any delivery period. + + Args: + url: URL of the trading API. + key: API key. + delivery_start: Start of the delivery period or None. + """ + client = Client(server_url=url, auth_key=key) + + print_trade_header() + + delivery_period = None + # If delivery period is selected, list historical trades also + if delivery_start is not None: + check_delivery_start(delivery_start) + delivery_period = DeliveryPeriod( + start=delivery_start, + duration=timedelta(minutes=15), + ) + lst = client.list_public_trades(delivery_period=delivery_period) + + async for trade in lst: + print_trade(trade) + + if delivery_start <= datetime.now(timezone.utc): + return + + stream = await client.stream_public_trades(delivery_period=delivery_period) + async for trade in stream: + print_trade(trade) + + +async def list_orders( + url: str, key: str, *, delivery_start: datetime, gid: int +) -> None: + """List orders and stream new orders. + + If delivery_start is provided, list historical orders and stream new orders + for the 15 minute delivery period starting at delivery_start. + If no delivery_start is provided, stream new orders for any delivery period. + + Args: + url: URL of the trading API. + key: API key. + delivery_start: Start of the delivery period or None. + gid: Gridpool ID. + """ + client = Client(server_url=url, auth_key=key) + + # print_header() + + delivery_period = None + # If delivery period is selected, list historical orders also + if delivery_start is not None: + check_delivery_start(delivery_start) + delivery_period = DeliveryPeriod( + start=delivery_start, + duration=timedelta(minutes=15), + ) + lst = client.list_gridpool_orders(gid, delivery_period=delivery_period) + + async for order in lst: + print_order(order) + + if delivery_start and delivery_start <= datetime.now(timezone.utc): + return + + stream = await client.stream_gridpool_orders(gid, delivery_period=delivery_period) + async for order in stream: + print_order(order) + + +def print_trade_header() -> None: + """Print trade header in CSV format.""" + header = ( + "public_trade_id, " + "execution_time, " + "delivery_period_start, " + "delivery_period_duration, " + "buy_delivery_area_code, " + "sell_delivery_area_code, " + "buy_delivery_area_code_type, " + "sell_delivery_area_code_type" + "quantity_mw, " + "price, " + "currency, " + "state, " + ) + print(header) + + +def print_trade(trade: PublicTrade) -> None: + """Print trade details to stdout in CSV format.""" + values = ( + trade.public_trade_id, + trade.execution_time.isoformat(), + trade.delivery_period.start.isoformat(), + trade.delivery_period.duration, + trade.buy_delivery_area.code, + trade.sell_delivery_area.code, + trade.buy_delivery_area.code_type, + trade.sell_delivery_area.code_type, + trade.quantity.mw, + trade.price.currency, + trade.price.amount, + trade.state, + ) + print(",".join(v.name if isinstance(v, Enum) else str(v) for v in values)) + + +def print_order_header() -> None: + """Print order header in CSV format.""" + header = ( + "order_id, " + "create_time, " + "modification_time, " + "delivery_period_start, " + "delivery_period_duration" + "delivery_area_code, " + "delivery_area_code_type, " + "order_type, " + "quantity_mw, " + "open_quantity_mw, " + "side, " + "currency, " + "price, " + "state, " + ) + print(header) + + +def print_order(order: OrderDetail) -> None: + """ + Print order details to stdout in CSV format. + + All fields except the following are printed: + - order.stop_price + - order.peak_price_delta + - order.display_quantity + - order.execution_option + - order.valid_until + - order.payload + - order.tag + - state_detail.state_reason + - state_detail.market_actor + - filled_quantity + + Args: + order: OrderDetail object + """ + values = [ + order.order_id, + order.create_time.isoformat(), + order.modification_time.isoformat(), + order.order.delivery_period.start.isoformat(), + order.order.delivery_period.duration, + order.order.delivery_area.code, + order.order.delivery_area.code_type, + order.order.type, + order.order.quantity.mw, + order.open_quantity.mw, + order.order.side, + order.order.price.currency, + order.order.price.amount, + order.state_detail.state, + ] + print(",".join(v.name if isinstance(v, Enum) else str(v) for v in values))