Skip to content

Commit 04f5253

Browse files
Add the public order book extension to the Client (#120)
This PR integrates the Public Order Book extension to the `Client`. More specifically, the changes made are: * Add the `PublicOrder` and `PublicOrderFilter` types * Add the `receive_public_order()` endpoint * Update the `frequenz-api-electricity-trading` to >= 0.6.1, < 0.7.0
2 parents b1abf99 + 9d6dc1c commit 04f5253

File tree

7 files changed

+386
-2
lines changed

7 files changed

+386
-2
lines changed

RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010
* Removed `list_public_trades`
1111
* Replaced `public_trades_stream` with `receive_public_trades`
1212
* `receive_public_trades` now supports an optional time range (`start_time`, `end_time`)
13+
* Update the `frequenz-api-electricity-trading` from >= 0.5.0, < 0.6.0 to >= 0.6.1, < 0.7.0
1314
* Update repo-config from v0.11.0 to v0.13.0
1415

1516
## New Features
1617

18+
* Add the Public Order Book extension to the client
19+
* Add the `PublicOrder` and `PublicOrderFilter` types
20+
* Add the `receive_public_order()` endpoint
1721

1822
## Bug Fixes
1923

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ dependencies = [
4545
"frequenz-channels >= 1.6.1, < 2",
4646
"frequenz-client-base >= 0.9.0, < 0.10.0",
4747
"frequenz-client-common >= 0.1.0, < 0.4.0",
48-
"frequenz-api-electricity-trading >= 0.5.0, < 0.6.0",
48+
"frequenz-api-electricity-trading >= 0.6.1, < 0.7.0",
4949
"protobuf >= 5.29.2, < 6",
5050
]
5151
dynamic = ["version"]

src/frequenz/client/electricity_trading/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ async def stream_trades():
204204
OrderType,
205205
Power,
206206
Price,
207+
PublicOrder,
208+
PublicOrderBookFilter,
207209
PublicTrade,
208210
PublicTradeFilter,
209211
StateDetail,
@@ -232,6 +234,8 @@ async def stream_trades():
232234
"OrderType",
233235
"Power",
234236
"Price",
237+
"PublicOrder",
238+
"PublicOrderBookFilter",
235239
"PublicTrade",
236240
"PublicTradeFilter",
237241
"UpdateOrder",

src/frequenz/client/electricity_trading/_client.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
OrderType,
4545
Power,
4646
Price,
47+
PublicOrder,
48+
PublicOrderBookFilter,
4749
PublicTrade,
4850
PublicTradeFilter,
4951
Trade,
@@ -197,6 +199,14 @@ def __init__(
197199
],
198200
] = {}
199201

202+
self._public_orders_streams: dict[
203+
PublicOrderBookFilter,
204+
GrpcStreamBroadcaster[
205+
electricity_trading_pb2.ReceivePublicOrderBookStreamResponse,
206+
PublicOrder,
207+
],
208+
] = {}
209+
200210
self._metadata = (("key", auth_key),) if auth_key else ()
201211

202212
@property
@@ -963,3 +973,74 @@ def dt_to_pb_timestamp(dt: datetime) -> Timestamp:
963973
_logger.exception("Error occurred while streaming public trades: %s", e)
964974
raise
965975
return self._public_trades_streams[public_trade_filter]
976+
977+
def receive_public_order_book(
978+
# pylint: disable=too-many-arguments, too-many-positional-arguments
979+
self,
980+
delivery_period: DeliveryPeriod | None = None,
981+
delivery_area: DeliveryArea | None = None,
982+
side: MarketSide | None = None,
983+
start_time: datetime | None = None,
984+
end_time: datetime | None = None,
985+
) -> GrpcStreamBroadcaster[
986+
electricity_trading_pb2.ReceivePublicOrderBookStreamResponse, PublicOrder
987+
]:
988+
"""
989+
Stream public orders with optional filters and time range.
990+
991+
Args:
992+
delivery_period: Delivery period to filter for.
993+
delivery_area: Delivery area to filter for.
994+
side: Side of the market to filter for.
995+
start_time: The starting timestamp to stream orders from. If None, streams from now.
996+
end_time: The ending timestamp to stop streaming orders. If None, streams indefinitely.
997+
998+
Returns:
999+
Async generator of orders.
1000+
1001+
Raises:
1002+
grpc.RpcError: If an error occurs while streaming public orders.
1003+
"""
1004+
1005+
def dt_to_pb_timestamp(dt: datetime) -> Timestamp:
1006+
ts = Timestamp()
1007+
ts.FromDatetime(dt)
1008+
return ts
1009+
1010+
public_order_filter = PublicOrderBookFilter(
1011+
delivery_period=delivery_period,
1012+
delivery_area=delivery_area,
1013+
side=side,
1014+
)
1015+
1016+
if (
1017+
public_order_filter not in self._public_orders_streams
1018+
or not self._public_orders_streams[public_order_filter].is_running
1019+
):
1020+
try:
1021+
self._public_orders_streams[public_order_filter] = (
1022+
GrpcStreamBroadcaster(
1023+
f"electricity-trading-{public_order_filter}",
1024+
lambda: self.stub.ReceivePublicOrderBookStream(
1025+
electricity_trading_pb2.ReceivePublicOrderBookStreamRequest(
1026+
filter=public_order_filter.to_pb(),
1027+
start_time=(
1028+
dt_to_pb_timestamp(start_time)
1029+
if start_time
1030+
else None
1031+
),
1032+
end_time=(
1033+
dt_to_pb_timestamp(end_time) if end_time else None
1034+
),
1035+
),
1036+
metadata=self._metadata,
1037+
),
1038+
lambda response: PublicOrder.from_pb(
1039+
response.public_order_book_record
1040+
),
1041+
)
1042+
)
1043+
except grpc.RpcError as e:
1044+
_logger.exception("Error occurred while streaming public orders: %s", e)
1045+
raise
1046+
return self._public_orders_streams[public_order_filter]

src/frequenz/client/electricity_trading/_types.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1944,3 +1944,195 @@ def to_pb(self) -> electricity_trading_pb2.UpdateGridpoolOrderRequest.UpdateOrde
19441944
payload=struct_pb2.Struct(fields=self.payload) if self.payload else None,
19451945
tag=self.tag if self.tag else None,
19461946
)
1947+
1948+
1949+
@dataclass()
1950+
class PublicOrder: # pylint: disable=too-many-instance-attributes
1951+
"""Represents a public order in the market."""
1952+
1953+
public_order_id: int
1954+
"""ID of the order from the public order book."""
1955+
1956+
delivery_area: DeliveryArea
1957+
"""Delivery area of the order."""
1958+
1959+
delivery_period: DeliveryPeriod
1960+
"""The delivery period for the contract."""
1961+
1962+
side: MarketSide
1963+
"""Indicates if the order is on the Buy or Sell side of the market."""
1964+
1965+
price: Price
1966+
"""The limit price at which the contract is to be traded."""
1967+
1968+
quantity: Power
1969+
"""The quantity of the contract being traded."""
1970+
1971+
execution_option: OrderExecutionOption
1972+
"""Order execution options such as All or None, Fill or Kill, etc."""
1973+
1974+
create_time: datetime
1975+
"""UTC Timestamp when the order was created."""
1976+
1977+
update_time: datetime
1978+
"""UTC Timestamp of the last update to the order."""
1979+
1980+
def __post_init__(self) -> None:
1981+
"""Post initialization checks to ensure that all datetimes are UTC."""
1982+
if self.delivery_period.start.tzinfo is None:
1983+
raise ValueError("Delivery period start must have timezone information")
1984+
if self.delivery_period.start.tzinfo != timezone.utc:
1985+
_logger.warning(
1986+
"Delivery period start is not in UTC timezone. Converting to UTC."
1987+
)
1988+
self.delivery_period.start = self.delivery_period.start.astimezone(
1989+
timezone.utc
1990+
)
1991+
1992+
@classmethod
1993+
@from_pb
1994+
def from_pb(
1995+
cls, public_order: electricity_trading_pb2.PublicOrderBookRecord
1996+
) -> Self:
1997+
"""Convert a protobuf PublicOrder to PublicOrder object.
1998+
1999+
Args:
2000+
public_order: PublicOrder to convert.
2001+
2002+
Returns:
2003+
PublicOrder object corresponding to the protobuf message.
2004+
"""
2005+
return cls(
2006+
public_order_id=public_order.id,
2007+
delivery_area=DeliveryArea.from_pb(public_order.delivery_area),
2008+
delivery_period=DeliveryPeriod.from_pb(public_order.delivery_period),
2009+
side=MarketSide.from_pb(public_order.side),
2010+
price=Price.from_pb(public_order.price),
2011+
quantity=Power.from_pb(public_order.quantity),
2012+
execution_option=OrderExecutionOption.from_pb(
2013+
public_order.execution_option
2014+
),
2015+
create_time=public_order.create_time.ToDatetime(tzinfo=timezone.utc),
2016+
update_time=public_order.update_time.ToDatetime(tzinfo=timezone.utc),
2017+
)
2018+
2019+
def to_pb(self) -> electricity_trading_pb2.PublicOrderBookRecord:
2020+
"""Convert a PublicOrder object to protobuf PublicOrder.
2021+
2022+
Returns:
2023+
Protobuf PublicOrder corresponding to the object.
2024+
"""
2025+
create_time = timestamp_pb2.Timestamp()
2026+
create_time.FromDatetime(self.create_time)
2027+
update_time = timestamp_pb2.Timestamp()
2028+
update_time.FromDatetime(self.update_time)
2029+
2030+
return electricity_trading_pb2.PublicOrderBookRecord(
2031+
id=self.public_order_id,
2032+
delivery_area=self.delivery_area.to_pb(),
2033+
delivery_period=self.delivery_period.to_pb(),
2034+
side=electricity_trading_pb2.MarketSide.ValueType(self.side.value),
2035+
price=self.price.to_pb(),
2036+
quantity=self.quantity.to_pb(),
2037+
execution_option=electricity_trading_pb2.OrderExecutionOption.ValueType(
2038+
self.execution_option.value
2039+
),
2040+
create_time=create_time,
2041+
update_time=update_time,
2042+
)
2043+
2044+
2045+
@dataclass()
2046+
class PublicOrderBookFilter:
2047+
"""Parameters for filtering the public orders in the market."""
2048+
2049+
delivery_period: DeliveryPeriod | None = None
2050+
"""Delivery period to filter for."""
2051+
2052+
delivery_area: DeliveryArea | None = None
2053+
"""Delivery area to filter for."""
2054+
2055+
side: MarketSide | None = None
2056+
"""Market side to filter for."""
2057+
2058+
def __eq__(self, other: object) -> bool:
2059+
"""
2060+
Check if two PublicOrderBookFilter objects are equal.
2061+
2062+
Args:
2063+
other: PublicOrderBookFilter object to compare with.
2064+
2065+
Returns:
2066+
True if the two PublicOrderBookFilter objects are equal, False otherwise.
2067+
"""
2068+
if not isinstance(other, PublicOrderBookFilter):
2069+
return NotImplemented
2070+
return (
2071+
self.delivery_period == other.delivery_period
2072+
and self.delivery_area == other.delivery_area
2073+
and self.side == other.side
2074+
)
2075+
2076+
def __hash__(self) -> int:
2077+
"""
2078+
Create hash of the PublicOrderBookFilter object.
2079+
2080+
Returns:
2081+
Hash of the PublicOrderBookFilter object.
2082+
"""
2083+
return hash(
2084+
(
2085+
self.delivery_period,
2086+
self.delivery_area,
2087+
self.side,
2088+
)
2089+
)
2090+
2091+
@classmethod
2092+
@from_pb
2093+
def from_pb(
2094+
cls, public_order_filter: electricity_trading_pb2.PublicOrderBookFilter
2095+
) -> Self:
2096+
"""Convert a protobuf PublicOrderBookFilter to PublicOrderBookFilter object.
2097+
2098+
Args:
2099+
public_order_filter: PublicOrderBookFilter to convert.
2100+
2101+
Returns:
2102+
PublicOrderBookFilter object corresponding to the protobuf message.
2103+
"""
2104+
return cls(
2105+
delivery_period=(
2106+
DeliveryPeriod.from_pb(public_order_filter.delivery_period)
2107+
if public_order_filter.HasField("delivery_period")
2108+
else None
2109+
),
2110+
delivery_area=(
2111+
DeliveryArea.from_pb(public_order_filter.delivery_area)
2112+
if public_order_filter.HasField("delivery_area")
2113+
else None
2114+
),
2115+
side=(
2116+
MarketSide.from_pb(public_order_filter.side)
2117+
if public_order_filter.HasField("side")
2118+
else None
2119+
),
2120+
)
2121+
2122+
def to_pb(self) -> electricity_trading_pb2.PublicOrderBookFilter:
2123+
"""Convert a PublicOrderBookFilter object to protobuf PublicOrderBookFilter.
2124+
2125+
Returns:
2126+
Protobuf PublicOrderBookFilter corresponding to the object.
2127+
"""
2128+
return electricity_trading_pb2.PublicOrderBookFilter(
2129+
delivery_period=(
2130+
self.delivery_period.to_pb() if self.delivery_period else None
2131+
),
2132+
delivery_area=self.delivery_area.to_pb() if self.delivery_area else None,
2133+
side=(
2134+
electricity_trading_pb2.MarketSide.ValueType(self.side.value)
2135+
if self.side
2136+
else None
2137+
),
2138+
)

tests/test_client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,21 @@ async def test_update_gridpool_order_with_invalid_params( # pylint: disable=too
465465
quantity=quantity,
466466
valid_until=valid_until,
467467
)
468+
469+
470+
async def test_receive_public_order_book(
471+
set_up: SetupParams,
472+
) -> None:
473+
"""Test the method receiving public order book."""
474+
set_up.client.receive_public_order_book(
475+
delivery_period=set_up.delivery_period,
476+
delivery_area=set_up.delivery_area,
477+
side=set_up.side,
478+
)
479+
await asyncio.sleep(0)
480+
481+
set_up.mock_stub.ReceivePublicOrderBookStream.assert_called_once()
482+
args, _ = set_up.mock_stub.ReceivePublicOrderBookStream.call_args
483+
assert args[0].filter.delivery_period == set_up.delivery_period.to_pb()
484+
assert args[0].filter.delivery_area == set_up.delivery_area.to_pb()
485+
assert args[0].filter.side == set_up.side.to_pb()

0 commit comments

Comments
 (0)