Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.


<!-- Here goes the main new features and examples or instructions on how to use them -->

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
84 changes: 59 additions & 25 deletions src/frequenz/client/electricity_trading/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to go in pyproject.toml as well, I think.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should take it from the "TZ" environment variable and use Berlin as the default, in case we want to call the command with UTC, etc., for whatever reason.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to keep this since this is the timezone used on EEX and EPEX, which is what is supported for day-ahead prices.


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()
204 changes: 204 additions & 0 deletions src/frequenz/client/electricity_trading/cli/etrading.py
Original file line number Diff line number Diff line change
@@ -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))
Loading