Skip to content

Commit ffdd934

Browse files
aniani
authored andcommitted
hermes support
1 parent 177a31b commit ffdd934

File tree

4 files changed

+239
-1
lines changed

4 files changed

+239
-1
lines changed

examples/read_hermes.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env python3
2+
3+
import asyncio
4+
5+
from pythclient.hermes import HermesClient, PriceFeed
6+
7+
async def get_hermes_prices():
8+
hermes_client = HermesClient([])
9+
feed_ids = await hermes_client.get_price_feed_ids()
10+
feed_ids_rel = feed_ids[:2]
11+
12+
hermes_client.add_feed_ids(feed_ids_rel)
13+
14+
prices_latest = await hermes_client.get_all_prices()
15+
16+
sd = list(prices_latest.keys())[0]
17+
import pdb; pdb.set_trace()
18+
19+
for feed_id, price_feed in prices_latest.items():
20+
print("Initial prices")
21+
price_latest = price_feed["price"].price
22+
conf_latest = price_feed["price"].conf
23+
print(f"Feed ID: {feed_id}, Price: {price_latest}, Confidence: {conf_latest}, Time: {price_feed['price'].publish_time}")
24+
25+
print("Starting web socket...")
26+
ws_call = hermes_client.ws_pyth_prices()
27+
asyncio.create_task(ws_call)
28+
29+
while True:
30+
await asyncio.sleep(5)
31+
print("Latest prices:")
32+
for feed_id, price_feed in hermes_client.prices_dict.items():
33+
print(f"Feed ID: {feed_id}, Price: {price_latest}, Confidence: {conf_latest}, Time: {price_feed['price'].publish_time}")
34+
35+
asyncio.run(get_hermes_prices())

pythclient/hermes.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import asyncio
2+
from typing import TypedDict
3+
4+
import httpx
5+
import os
6+
7+
from .price_feeds import Price
8+
9+
HERMES_ENDPOINT_HTTPS = "https://hermes.pyth.network/api/"
10+
HERMES_ENDPOINT_WSS = "wss://hermes.pyth.network/ws"
11+
12+
13+
class PriceFeed(TypedDict):
14+
feed_id: str
15+
price: Price
16+
ema_price: Price
17+
vaa: str
18+
19+
20+
21+
class HermesClient:
22+
def __init__(self, feed_ids: list[str], endpoint=HERMES_ENDPOINT_HTTPS, ws_endpoint=HERMES_ENDPOINT_WSS):
23+
self.feed_ids = feed_ids
24+
self.pending_feed_ids = feed_ids
25+
self.prices_dict: dict[str, PriceFeed] = {}
26+
self.client = httpx.AsyncClient()
27+
self.endpoint = endpoint
28+
self.ws_endpoint = ws_endpoint
29+
30+
async def get_price_feed_ids(self) -> list[str]:
31+
"""
32+
Queries the Hermes https endpoint for a list of the IDs of all Pyth price feeds.
33+
"""
34+
35+
url = os.path.join(self.endpoint, "price_feed_ids")
36+
37+
client = httpx.AsyncClient()
38+
39+
data = (await client.get(url)).json()
40+
41+
return data
42+
43+
def add_feed_ids(self, feed_ids: list[str]):
44+
self.feed_ids += feed_ids
45+
self.feed_ids = list(set(self.feed_ids))
46+
self.pending_feed_ids += feed_ids
47+
48+
@staticmethod
49+
def extract_price_feed(data: dict) -> PriceFeed:
50+
"""
51+
Extracts a PriceFeed object from the JSON response from Hermes.
52+
"""
53+
price = Price.from_dict(data["price"])
54+
ema_price = Price.from_dict(data["ema_price"])
55+
vaa = data["vaa"]
56+
price_feed = {
57+
"feed_id": data["id"],
58+
"price": price,
59+
"ema_price": ema_price,
60+
"vaa": vaa,
61+
}
62+
return price_feed
63+
64+
async def get_pyth_prices_latest(self, feedIds: list[str]) -> list[PriceFeed]:
65+
"""
66+
Queries the Hermes https endpoint for the latest price feeds for a list of Pyth feed IDs.
67+
"""
68+
url = os.path.join(self.endpoint, "latest_price_feeds?")
69+
params = {"ids[]": feedIds, "binary": "true"}
70+
71+
data = (await self.client.get(url, params=params)).json()
72+
73+
results = []
74+
for res in data:
75+
price_feed = self.extract_price_feed(res)
76+
results.append(price_feed)
77+
78+
return results
79+
80+
async def get_pyth_price_at_time(self, feed_id: str, timestamp: int) -> PriceFeed:
81+
"""
82+
Queries the Hermes https endpoint for the price feed for a Pyth feed ID at a given timestamp.
83+
"""
84+
url = os.path.join(self.endpoint, "get_price_feed")
85+
params = {"id": feed_id, "publish_time": timestamp, "binary": "true"}
86+
87+
data = (await self.client.get(url, params=params)).json()
88+
89+
price_feed = self.extract_price_feed(data)
90+
91+
return price_feed
92+
93+
async def get_all_prices(self) -> dict[str, PriceFeed]:
94+
"""
95+
Queries the Hermes http endpoint for the latest price feeds for all feed IDs in the class object.
96+
97+
There are limitations on the number of feed IDs that can be queried at once, so this function queries the feed IDs in batches.
98+
"""
99+
pyth_prices_latest = []
100+
i = 0
101+
batch_size = 100
102+
while len(self.feed_ids[i : i + batch_size]) > 0:
103+
pyth_prices_latest += await self.get_pyth_prices_latest(
104+
self.feed_ids[i : i + batch_size]
105+
)
106+
i += batch_size
107+
108+
return dict([(feed['feed_id'], feed) for feed in pyth_prices_latest])
109+
110+
async def ws_pyth_prices(self):
111+
"""
112+
Opens a websocket connection to Hermes for latest prices for all feed IDs in the class object.
113+
"""
114+
import json
115+
116+
import websockets
117+
118+
async with websockets.connect(self.ws_endpoint) as ws:
119+
while True:
120+
# add new price feed ids to the ws subscription
121+
if len(self.pending_feed_ids) > 0:
122+
json_subscribe = {
123+
"ids": self.pending_feed_ids,
124+
"type": "subscribe",
125+
"verbose": True,
126+
"binary": True,
127+
}
128+
await ws.send(json.dumps(json_subscribe))
129+
self.pending_feed_ids = []
130+
131+
msg = json.loads(await ws.recv())
132+
if msg.get("type") == "response":
133+
if msg.get("status") != "success":
134+
raise Exception("Error in subscribing to websocket")
135+
try:
136+
if msg["type"] != "price_update":
137+
continue
138+
139+
feed_id = msg["price_feed"]["id"]
140+
new_feed = msg["price_feed"]
141+
142+
self.prices_dict[feed_id] = self.extract_price_feed(new_feed)
143+
144+
except:
145+
raise Exception("Error in price_update message", msg)
146+
147+
148+
async def main():
149+
hermes_client = HermesClient([])
150+
feed_ids = await hermes_client.get_price_feed_ids()
151+
feed_ids_rel = feed_ids[:50]
152+
153+
hermes_client.add_feed_ids(feed_ids_rel)
154+
155+
prices_latest = await hermes_client.get_pyth_prices_latest(feed_ids_rel)
156+
157+
try:
158+
price_at_time = await hermes_client.get_pyth_price_at_time(feed_ids[0], 1_700_000_000)
159+
except Exception as e:
160+
print(f"Error in get_pyth_price_at_time, {e}")
161+
162+
all_prices = await hermes_client.get_all_prices()
163+
164+
print("Starting web socket...")
165+
ws_call = hermes_client.ws_pyth_prices()
166+
asyncio.create_task(ws_call)
167+
168+
while True:
169+
await asyncio.sleep(1)
170+
171+
172+
if __name__ == "__main__":
173+
asyncio.run(main())

pythclient/price_feeds.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import base64
22
import binascii
33
from struct import unpack
4-
from typing import List, Literal, Optional, Union, cast
4+
from typing import List, Literal, Optional, Union, cast, TypedDict
55

66
from Crypto.Hash import keccak
77
from loguru import logger
@@ -17,6 +17,11 @@
1717

1818
MAX_MESSAGE_IN_SINGLE_UPDATE_DATA = 255
1919

20+
class PriceDict(TypedDict):
21+
conf: str
22+
expo: int
23+
price: str
24+
publish_time: int
2025

2126
class Price:
2227
def __init__(self, conf, expo, price, publish_time) -> None:
@@ -35,6 +40,16 @@ def to_dict(self):
3540
"price": self.price,
3641
"publish_time": self.publish_time,
3742
}
43+
44+
@staticmethod
45+
def from_dict(price_dict: PriceDict):
46+
return Price(
47+
conf=int(price_dict["conf"]),
48+
expo=price_dict["expo"],
49+
price=int(price_dict["price"]),
50+
publish_time=price_dict["publish_time"],
51+
)
52+
3853

3954

4055
class PriceUpdate:

tests/test_hermes.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from pythclient.hermes import HermesClient, PriceFeed
2+
3+
BTC_ID = "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" # BTC/USD
4+
ETH_ID = "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace" # ETH/USD
5+
6+
async def test_hermes_return_price_feed_object():
7+
# Test that the hermes get request returns a dict with same keys as PriceFeed
8+
hermes_client = HermesClient([])
9+
hermes_client.add_feed_ids([BTC_ID, ETH_ID])
10+
11+
all_prices = await hermes_client.get_all_prices()
12+
13+
assert isinstance(all_prices, dict)
14+
assert set(all_prices[BTC_ID].keys()) == set(PriceFeed.__annotations__.keys())
15+
assert set(all_prices[ETH_ID].keys()) == set(PriceFeed.__annotations__.keys())

0 commit comments

Comments
 (0)