Skip to content

Commit 0bd2f73

Browse files
authored
Add CLI command to create limit orders (#97)
The command allows creating a limit order on a test instance. The market side is derived from the sign of the quantity. For now only delivery periods of 15 min and delivery area codes in EUROPE_EIC format are supported. In addition to that the sort order of listed orders is reversed such that oldest orders are listed at the bottom (restricted within chunks).
2 parents 6cf69ef + 5f90e5e commit 0bd2f73

File tree

3 files changed

+188
-1
lines changed

3 files changed

+188
-1
lines changed

RELEASE_NOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
* `list-day-ahead`: Listing day-ahead prices from the entsoe API.
1515
* `list-trades`: Listing and streaming public trades for specified delivery periods. If no delivery start is given, starts streaming all new public trades.
1616
* `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+
* `create-order`: Creating a limit order for a given price (in EUR/MWh) and quantity (in MW, sign determines market side).
18+
* `cancel-order`: Cancel individual orders for a gridpool.
19+
* `cancel-all-orders`: Cancels all orders of a gridpool.
1720

1821

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

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
import click
1111

1212
from frequenz.client.electricity_trading.cli.day_ahead import list_day_ahead_prices
13+
from frequenz.client.electricity_trading.cli.etrading import (
14+
cancel_order as run_cancel_order,
15+
)
16+
from frequenz.client.electricity_trading.cli.etrading import (
17+
create_order as run_create_order,
18+
)
1319
from frequenz.client.electricity_trading.cli.etrading import (
1420
list_orders as run_list_orders,
1521
)
@@ -54,6 +60,70 @@ def list_orders(url: str, key: str, *, start: datetime, gid: int) -> None:
5460
asyncio.run(run_list_orders(url=url, key=key, delivery_start=start, gid=gid))
5561

5662

63+
@cli.command()
64+
@click.option("--url", required=True, type=str)
65+
@click.option("--key", required=True, type=str)
66+
@click.option("--start", required=True, type=iso)
67+
@click.option("--gid", required=True, type=int)
68+
@click.option("--quantity", required=True, type=str)
69+
@click.option("--price", required=True, type=str)
70+
@click.option("--area", required=True, type=str)
71+
@click.option("--currency", default="EUR", type=str)
72+
@click.option("--duration", default=900, type=int)
73+
def create_order(
74+
# pylint: disable=too-many-arguments
75+
url: str,
76+
key: str,
77+
*,
78+
start: datetime,
79+
gid: int,
80+
quantity: str,
81+
price: str,
82+
area: str,
83+
currency: str,
84+
duration: int,
85+
) -> None:
86+
"""Create an order.
87+
88+
This is only allowed in test instances.
89+
"""
90+
if "test" not in url:
91+
raise ValueError("Creating orders is only allowed in test instances.")
92+
93+
asyncio.run(
94+
run_create_order(
95+
url=url,
96+
key=key,
97+
delivery_start=start,
98+
gid=gid,
99+
quantity_mw=quantity,
100+
price=price,
101+
delivery_area=area,
102+
currency=currency,
103+
duration=timedelta(seconds=duration),
104+
)
105+
)
106+
107+
108+
@cli.command()
109+
@click.option("--url", required=True, type=str)
110+
@click.option("--key", required=True, type=str)
111+
@click.option("--gid", required=True, type=int)
112+
@click.option("--order", required=True, type=int)
113+
def cancel_order(url: str, key: str, gid: int, order: int) -> None:
114+
"""Cancel an order."""
115+
asyncio.run(run_cancel_order(url=url, key=key, gridpool_id=gid, order_id=order))
116+
117+
118+
@cli.command()
119+
@click.option("--url", required=True, type=str)
120+
@click.option("--key", required=True, type=str)
121+
@click.option("--gid", required=True, type=int)
122+
def cancel_all_orders(url: str, key: str, gid: int) -> None:
123+
"""Cancel all orders for a gridpool ID."""
124+
asyncio.run(run_cancel_order(url=url, key=key, gridpool_id=gid, order_id=None))
125+
126+
57127
@cli.command()
58128
@click.option("--entsoe-key", required=True, type=str)
59129
@click.option("--start", default=midnight(), type=iso)

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

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,23 @@
33

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

6+
from collections import deque
67
from datetime import datetime, timedelta, timezone
8+
from decimal import Decimal
79
from enum import Enum
10+
from typing import AsyncIterator
811

912
from frequenz.client.electricity_trading import (
1013
Client,
14+
Currency,
15+
DeliveryArea,
1116
DeliveryPeriod,
17+
EnergyMarketCodeType,
18+
MarketSide,
1219
OrderDetail,
20+
OrderType,
21+
Power,
22+
Price,
1323
PublicTrade,
1424
)
1525

@@ -76,6 +86,9 @@ async def list_orders(
7686
for the 15 minute delivery period starting at delivery_start.
7787
If no delivery_start is provided, stream new orders for any delivery period.
7888
89+
Note that retrieved sort order for listed orders (starting from the newest)
90+
is reversed in chunks trying to bring more recent orders to the bottom.
91+
7992
Args:
8093
url: URL of the trading API.
8194
key: API key.
@@ -96,7 +109,7 @@ async def list_orders(
96109
)
97110
lst = client.list_gridpool_orders(gid, delivery_period=delivery_period)
98111

99-
async for order in lst:
112+
async for order in reverse_iterator(lst):
100113
print_order(order)
101114

102115
if delivery_start and delivery_start <= datetime.now(timezone.utc):
@@ -107,6 +120,83 @@ async def list_orders(
107120
print_order(order)
108121

109122

123+
# pylint: disable=too-many-arguments
124+
async def create_order(
125+
url: str,
126+
key: str,
127+
*,
128+
gid: int,
129+
delivery_start: datetime,
130+
delivery_area: str,
131+
price: str,
132+
quantity_mw: str,
133+
currency: str,
134+
duration: timedelta,
135+
) -> None:
136+
"""Create a limit order for a given price and quantity (in MW).
137+
138+
The market side is determined by the sign of the quantity, positive for buy orders
139+
and negative for sell orders. The delivery area code is expected to be in
140+
EUROPE_EIC format.
141+
142+
Args:
143+
url: URL of the trading API.
144+
key: API key.
145+
gid: Gridpool ID.
146+
delivery_start: Start of the delivery period.
147+
delivery_area: Delivery area code.
148+
price: Price of the order.
149+
quantity_mw: Quantity in MW, positive for buy orders and negative for sell orders.
150+
currency: Currency of the price.
151+
duration: Duration of the delivery period.
152+
"""
153+
client = Client(server_url=url, auth_key=key)
154+
155+
side = MarketSide.SELL if quantity_mw[0] == "-" else MarketSide.BUY
156+
quantity = Power(mw=Decimal(quantity_mw.lstrip("-")))
157+
check_delivery_start(delivery_start)
158+
order = await client.create_gridpool_order(
159+
gridpool_id=gid,
160+
delivery_area=DeliveryArea(
161+
code=delivery_area,
162+
code_type=EnergyMarketCodeType.EUROPE_EIC,
163+
),
164+
delivery_period=DeliveryPeriod(
165+
start=delivery_start,
166+
duration=duration,
167+
),
168+
order_type=OrderType.LIMIT,
169+
side=side,
170+
price=Price(
171+
amount=Decimal(price),
172+
currency=Currency[currency],
173+
),
174+
quantity=quantity,
175+
)
176+
177+
print_order(order)
178+
179+
180+
async def cancel_order(
181+
url: str, key: str, *, gridpool_id: int, order_id: int | None
182+
) -> None:
183+
"""Cancel an order by order ID.
184+
185+
If order_id is None, cancel all orders in the gridpool.
186+
187+
Args:
188+
url: URL of the trading API.
189+
key: API key.
190+
gridpool_id: Gridpool ID.
191+
order_id: Order ID to cancel or None to cancel all orders.
192+
"""
193+
client = Client(server_url=url, auth_key=key)
194+
if order_id is None:
195+
await client.cancel_all_gridpool_orders(gridpool_id)
196+
else:
197+
await client.cancel_gridpool_order(gridpool_id, order_id)
198+
199+
110200
def print_trade_header() -> None:
111201
"""Print trade header in CSV format."""
112202
header = (
@@ -202,3 +292,27 @@ def print_order(order: OrderDetail) -> None:
202292
order.state_detail.state,
203293
]
204294
print(",".join(v.name if isinstance(v, Enum) else str(v) for v in values))
295+
296+
297+
async def reverse_iterator(
298+
iterator: AsyncIterator[OrderDetail], chunk_size: int = 100_000
299+
) -> AsyncIterator[OrderDetail]:
300+
"""Reverse an async iterator in chunks to avoid loading all elements into memory.
301+
302+
Args:
303+
iterator: Async iterator to reverse.
304+
chunk_size: Size of the buffer to store elements.
305+
306+
Yields:
307+
Elements of the iterator in reverse order.
308+
"""
309+
buffer: deque[OrderDetail] = deque(maxlen=chunk_size)
310+
async for item in iterator:
311+
buffer.append(item)
312+
if len(buffer) == chunk_size:
313+
for item in reversed(buffer):
314+
yield item
315+
buffer.clear()
316+
if buffer:
317+
for item in reversed(buffer):
318+
yield item

0 commit comments

Comments
 (0)