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
3 changes: 3 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.


<!-- Here goes the main new features and examples or instructions on how to use them -->
Expand Down
70 changes: 70 additions & 0 deletions src/frequenz/client/electricity_trading/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

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

I think some of these could use a description, I've added an issue: #99

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

You mean as a help message?

Copy link
Contributor

Choose a reason for hiding this comment

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

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)
Expand Down
116 changes: 115 additions & 1 deletion src/frequenz/client/electricity_trading/cli/etrading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

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

I had started a review on the other PR yesterday but I was too slow and it got merged before I could finish it uups.
One point I had is that streaming orders in a list_* function feels a bit misleading to me. I’d prefer separating those, but I’m not strongly opposed if others feel strongly about keeping it as is.

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 think it's useful to have something that does listing and then continues streaming, in fact it feels like a shortcoming of the current API to not support that. If you object the name we could call it receive_?

Copy link
Contributor

Choose a reason for hiding this comment

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

yep renaming it would help!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Will do: #98


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.
Expand All @@ -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):
Expand All @@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

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

Hm this feels a bit dangerous, like if someone forgets the order_id we could risk having all orders cancelled unintentionally no?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

So you prefer to split it into cancel_order and cancel_all_orders?

Copy link
Contributor

Choose a reason for hiding this comment

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

Indeed

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


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 = (
Expand Down Expand Up @@ -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
Loading