Skip to content

Commit 6cf69ef

Browse files
authored
Introduces CLI commands to interact with the trading API: (#96)
- `list_trades` allows listing and streaming public trades for specified delivery periods. If no delivery start is given, starts streaming all new public trades. - `list_orders` allows 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. This also changes the argument parsing for the day-ahead command to use the click library to support more complex interactions. In future the listing of day ahead prices from the entsoe API might be moved into a separate CLI tool.
2 parents a5f0a41 + 23c37e9 commit 6cf69ef

File tree

4 files changed

+269
-26
lines changed

4 files changed

+269
-26
lines changed

RELEASE_NOTES.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010

1111
## New Features
1212

13-
* 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.
13+
* Add trading-cli tool to interact with the trading API. Supports the following commands:
14+
* `list-day-ahead`: Listing day-ahead prices from the entsoe API.
15+
* `list-trades`: Listing and streaming public trades for specified delivery periods. If no delivery start is given, starts streaming all new public trades.
16+
* `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.
17+
1418

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

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ classifiers = [
2727
requires-python = ">= 3.11, < 4"
2828
# TODO(cookiecutter): Remove and add more dependencies if appropriate
2929
dependencies = [
30+
"click >= 8.1.8, < 9",
3031
"entsoe-py >= 0.6.16, < 1",
3132
"frequenz-api-common >= 0.6.3, < 0.7.0",
3233
"grpcio >= 1.66.2, < 2",

src/frequenz/client/electricity_trading/cli/__main__.py

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,75 @@
33

44
"""CLI tool to interact with the trading API."""
55

6-
import argparse
6+
import asyncio
77
from datetime import datetime, timedelta
88
from zoneinfo import ZoneInfo
99

10+
import click
11+
1012
from frequenz.client.electricity_trading.cli.day_ahead import list_day_ahead_prices
13+
from frequenz.client.electricity_trading.cli.etrading import (
14+
list_orders as run_list_orders,
15+
)
16+
from frequenz.client.electricity_trading.cli.etrading import (
17+
list_trades as run_list_trades,
18+
)
1119

20+
TZ = ZoneInfo("Europe/Berlin")
1221

13-
def main() -> None:
14-
"""Run main entry point for the CLI tool."""
15-
tz = ZoneInfo("Europe/Berlin")
16-
midnight = datetime.combine(datetime.now(tz), datetime.min.time(), tzinfo=tz)
17-
parser = argparse.ArgumentParser()
18-
parser.add_argument("--entsoe_key", type=str, required=True)
19-
parser.add_argument(
20-
"--start",
21-
type=datetime.fromisoformat,
22-
required=False,
23-
default=midnight,
24-
)
25-
parser.add_argument(
26-
"--end",
27-
type=datetime.fromisoformat,
28-
required=False,
29-
default=midnight + timedelta(days=2),
30-
)
31-
parser.add_argument("--country_code", type=str, required=False, default="DE_LU")
32-
args = parser.parse_args()
22+
iso = datetime.fromisoformat
23+
24+
25+
def midnight(days: int = 0) -> str:
26+
"""Return today's midnight."""
27+
return (
28+
datetime.combine(datetime.now(TZ), datetime.min.time(), tzinfo=TZ)
29+
+ timedelta(days)
30+
).isoformat()
31+
32+
33+
@click.group()
34+
def cli() -> None:
35+
"""CLI tool to interact with the trading API."""
3336

37+
38+
@cli.command()
39+
@click.option("--url", required=True, type=str)
40+
@click.option("--key", required=True, type=str)
41+
@click.option("--start", default=None, type=iso)
42+
def list_trades(url: str, key: str, *, start: datetime) -> None:
43+
"""List trades."""
44+
asyncio.run(run_list_trades(url=url, key=key, delivery_start=start))
45+
46+
47+
@cli.command()
48+
@click.option("--url", required=True, type=str)
49+
@click.option("--key", required=True, type=str)
50+
@click.option("--start", default=None, type=iso)
51+
@click.option("--gid", required=True, type=int)
52+
def list_orders(url: str, key: str, *, start: datetime, gid: int) -> None:
53+
"""List orders."""
54+
asyncio.run(run_list_orders(url=url, key=key, delivery_start=start, gid=gid))
55+
56+
57+
@cli.command()
58+
@click.option("--entsoe-key", required=True, type=str)
59+
@click.option("--start", default=midnight(), type=iso)
60+
@click.option("--end", default=midnight(days=2), type=iso)
61+
@click.option("--country-code", type=str, default="DE_LU")
62+
def list_day_ahead(
63+
entsoe_key: str, *, start: datetime, end: datetime, country_code: str
64+
) -> None:
65+
"""List day-ahead prices."""
3466
list_day_ahead_prices(
35-
entsoe_key=args.entsoe_key,
36-
start=args.start,
37-
end=args.end,
38-
country_code=args.country_code,
67+
entsoe_key=entsoe_key, start=start, end=end, country_code=country_code
3968
)
4069

4170

71+
def main() -> None:
72+
"""Run the main Click CLI."""
73+
cli()
74+
75+
4276
if __name__ == "__main__":
4377
main()
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""CLI tool to interact with the trading API."""
5+
6+
from datetime import datetime, timedelta, timezone
7+
from enum import Enum
8+
9+
from frequenz.client.electricity_trading import (
10+
Client,
11+
DeliveryPeriod,
12+
OrderDetail,
13+
PublicTrade,
14+
)
15+
16+
17+
def check_delivery_start(
18+
ts: datetime, duration: timedelta = timedelta(minutes=15)
19+
) -> None:
20+
"""Validate that the delivery start is a multiple of duration.
21+
22+
Args:
23+
ts: Delivery start timestamp.
24+
duration: Delivery period duration.
25+
26+
Raises:
27+
ValueError: If `ts` is not a multiple of `duration`.
28+
"""
29+
if int(ts.timestamp()) % int(duration.total_seconds()) != 0:
30+
raise ValueError("Delivery period must be a multiple of `duration`.")
31+
32+
33+
async def list_trades(url: str, key: str, *, delivery_start: datetime) -> None:
34+
"""List trades and stream new trades.
35+
36+
If delivery_start is provided, list historical trades and stream new trades
37+
for the 15 minute delivery period starting at delivery_start.
38+
If no delivery_start is provided, stream new trades for any delivery period.
39+
40+
Args:
41+
url: URL of the trading API.
42+
key: API key.
43+
delivery_start: Start of the delivery period or None.
44+
"""
45+
client = Client(server_url=url, auth_key=key)
46+
47+
print_trade_header()
48+
49+
delivery_period = None
50+
# If delivery period is selected, list historical trades also
51+
if delivery_start is not None:
52+
check_delivery_start(delivery_start)
53+
delivery_period = DeliveryPeriod(
54+
start=delivery_start,
55+
duration=timedelta(minutes=15),
56+
)
57+
lst = client.list_public_trades(delivery_period=delivery_period)
58+
59+
async for trade in lst:
60+
print_trade(trade)
61+
62+
if delivery_start <= datetime.now(timezone.utc):
63+
return
64+
65+
stream = await client.stream_public_trades(delivery_period=delivery_period)
66+
async for trade in stream:
67+
print_trade(trade)
68+
69+
70+
async def list_orders(
71+
url: str, key: str, *, delivery_start: datetime, gid: int
72+
) -> None:
73+
"""List orders and stream new orders.
74+
75+
If delivery_start is provided, list historical orders and stream new orders
76+
for the 15 minute delivery period starting at delivery_start.
77+
If no delivery_start is provided, stream new orders for any delivery period.
78+
79+
Args:
80+
url: URL of the trading API.
81+
key: API key.
82+
delivery_start: Start of the delivery period or None.
83+
gid: Gridpool ID.
84+
"""
85+
client = Client(server_url=url, auth_key=key)
86+
87+
# print_header()
88+
89+
delivery_period = None
90+
# If delivery period is selected, list historical orders also
91+
if delivery_start is not None:
92+
check_delivery_start(delivery_start)
93+
delivery_period = DeliveryPeriod(
94+
start=delivery_start,
95+
duration=timedelta(minutes=15),
96+
)
97+
lst = client.list_gridpool_orders(gid, delivery_period=delivery_period)
98+
99+
async for order in lst:
100+
print_order(order)
101+
102+
if delivery_start and delivery_start <= datetime.now(timezone.utc):
103+
return
104+
105+
stream = await client.stream_gridpool_orders(gid, delivery_period=delivery_period)
106+
async for order in stream:
107+
print_order(order)
108+
109+
110+
def print_trade_header() -> None:
111+
"""Print trade header in CSV format."""
112+
header = (
113+
"public_trade_id, "
114+
"execution_time, "
115+
"delivery_period_start, "
116+
"delivery_period_duration, "
117+
"buy_delivery_area_code, "
118+
"sell_delivery_area_code, "
119+
"buy_delivery_area_code_type, "
120+
"sell_delivery_area_code_type"
121+
"quantity_mw, "
122+
"price, "
123+
"currency, "
124+
"state, "
125+
)
126+
print(header)
127+
128+
129+
def print_trade(trade: PublicTrade) -> None:
130+
"""Print trade details to stdout in CSV format."""
131+
values = (
132+
trade.public_trade_id,
133+
trade.execution_time.isoformat(),
134+
trade.delivery_period.start.isoformat(),
135+
trade.delivery_period.duration,
136+
trade.buy_delivery_area.code,
137+
trade.sell_delivery_area.code,
138+
trade.buy_delivery_area.code_type,
139+
trade.sell_delivery_area.code_type,
140+
trade.quantity.mw,
141+
trade.price.currency,
142+
trade.price.amount,
143+
trade.state,
144+
)
145+
print(",".join(v.name if isinstance(v, Enum) else str(v) for v in values))
146+
147+
148+
def print_order_header() -> None:
149+
"""Print order header in CSV format."""
150+
header = (
151+
"order_id, "
152+
"create_time, "
153+
"modification_time, "
154+
"delivery_period_start, "
155+
"delivery_period_duration"
156+
"delivery_area_code, "
157+
"delivery_area_code_type, "
158+
"order_type, "
159+
"quantity_mw, "
160+
"open_quantity_mw, "
161+
"side, "
162+
"currency, "
163+
"price, "
164+
"state, "
165+
)
166+
print(header)
167+
168+
169+
def print_order(order: OrderDetail) -> None:
170+
"""
171+
Print order details to stdout in CSV format.
172+
173+
All fields except the following are printed:
174+
- order.stop_price
175+
- order.peak_price_delta
176+
- order.display_quantity
177+
- order.execution_option
178+
- order.valid_until
179+
- order.payload
180+
- order.tag
181+
- state_detail.state_reason
182+
- state_detail.market_actor
183+
- filled_quantity
184+
185+
Args:
186+
order: OrderDetail object
187+
"""
188+
values = [
189+
order.order_id,
190+
order.create_time.isoformat(),
191+
order.modification_time.isoformat(),
192+
order.order.delivery_period.start.isoformat(),
193+
order.order.delivery_period.duration,
194+
order.order.delivery_area.code,
195+
order.order.delivery_area.code_type,
196+
order.order.type,
197+
order.order.quantity.mw,
198+
order.open_quantity.mw,
199+
order.order.side,
200+
order.order.price.currency,
201+
order.order.price.amount,
202+
order.state_detail.state,
203+
]
204+
print(",".join(v.name if isinstance(v, Enum) else str(v) for v in values))

0 commit comments

Comments
 (0)