Skip to content

Commit 8389737

Browse files
aniani
authored andcommitted
address comments
1 parent 5691b9f commit 8389737

File tree

3 files changed

+192
-53
lines changed

3 files changed

+192
-53
lines changed

examples/read_hermes.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ async def get_hermes_prices():
88
hermes_client = HermesClient([])
99
feed_ids = await hermes_client.get_price_feed_ids()
1010
feed_ids_rel = feed_ids[:2]
11+
version = 1
1112

1213
hermes_client.add_feed_ids(feed_ids_rel)
1314

14-
prices_latest = await hermes_client.get_all_prices()
15+
prices_latest = await hermes_client.get_all_prices(version=version)
1516

1617
sd = list(prices_latest.keys())[0]
17-
import pdb; pdb.set_trace()
1818

1919
for feed_id, price_feed in prices_latest.items():
2020
print("Initial prices")
@@ -23,7 +23,7 @@ async def get_hermes_prices():
2323
print(f"Feed ID: {feed_id}, Price: {price_latest}, Confidence: {conf_latest}, Time: {price_feed['price'].publish_time}")
2424

2525
print("Starting web socket...")
26-
ws_call = hermes_client.ws_pyth_prices()
26+
ws_call = hermes_client.ws_pyth_prices(version=version)
2727
asyncio.create_task(ws_call)
2828

2929
while True:

pythclient/hermes.py

Lines changed: 87 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,48 @@
11
import asyncio
22
from typing import TypedDict
3-
43
import httpx
54
import os
5+
import json
6+
import websockets
67

78
from .price_feeds import Price
89

9-
HERMES_ENDPOINT_HTTPS = "https://hermes.pyth.network/api/"
10+
HERMES_ENDPOINT_HTTPS = "https://hermes.pyth.network/"
1011
HERMES_ENDPOINT_WSS = "wss://hermes.pyth.network/ws"
1112

1213

1314
class PriceFeed(TypedDict):
1415
feed_id: str
1516
price: Price
1617
ema_price: Price
17-
vaa: str
18+
update_data: list[str]
19+
1820

21+
def parse_unsupported_version(version):
22+
if isinstance(version, int):
23+
raise ValueError("Version number {version} not supported")
24+
else:
25+
raise TypeError("Version must be an integer")
1926

2027

2128
class HermesClient:
22-
def __init__(self, feed_ids: list[str], endpoint=HERMES_ENDPOINT_HTTPS, ws_endpoint=HERMES_ENDPOINT_WSS):
29+
def __init__(self, feed_ids: list[str], endpoint=HERMES_ENDPOINT_HTTPS, ws_endpoint=HERMES_ENDPOINT_WSS, feed_batch_size=100):
2330
self.feed_ids = feed_ids
2431
self.pending_feed_ids = feed_ids
2532
self.prices_dict: dict[str, PriceFeed] = {}
2633
self.client = httpx.AsyncClient()
2734
self.endpoint = endpoint
2835
self.ws_endpoint = ws_endpoint
36+
self.feed_batch_size = feed_batch_size
2937

3038
async def get_price_feed_ids(self) -> list[str]:
3139
"""
3240
Queries the Hermes https endpoint for a list of the IDs of all Pyth price feeds.
3341
"""
3442

35-
url = os.path.join(self.endpoint, "price_feed_ids")
36-
37-
client = httpx.AsyncClient()
43+
url = os.path.join(self.endpoint, "api/price_feed_ids")
3844

39-
data = (await client.get(url)).json()
45+
data = (await self.client.get(url)).json()
4046

4147
return data
4248

@@ -46,74 +52,113 @@ def add_feed_ids(self, feed_ids: list[str]):
4652
self.pending_feed_ids += feed_ids
4753

4854
@staticmethod
49-
def extract_price_feed(data: dict) -> PriceFeed:
55+
def extract_price_feed_v1(data: dict) -> PriceFeed:
5056
"""
51-
Extracts a PriceFeed object from the JSON response from Hermes.
57+
Extracts PriceFeed object from the v1 JSON response (individual price feed) from Hermes.
5258
"""
5359
price = Price.from_dict(data["price"])
5460
ema_price = Price.from_dict(data["ema_price"])
55-
vaa = data["vaa"]
61+
update_data = data["vaa"]
5662
price_feed = {
5763
"feed_id": data["id"],
5864
"price": price,
5965
"ema_price": ema_price,
60-
"vaa": vaa,
66+
"update_data": [update_data],
6167
}
6268
return price_feed
69+
70+
@staticmethod
71+
def extract_price_feed_v2(data: dict) -> list[PriceFeed]:
72+
"""
73+
Extracts PriceFeed objects from the v2 JSON response (multiple price feeds) from Hermes.
74+
"""
75+
update_data = data["binary"]["data"]
6376

64-
async def get_pyth_prices_latest(self, feedIds: list[str]) -> list[PriceFeed]:
77+
price_feeds = []
78+
79+
for feed in data["parsed"]:
80+
price = Price.from_dict(feed["price"])
81+
ema_price = Price.from_dict(feed["ema_price"])
82+
price_feed = {
83+
"feed_id": feed["id"],
84+
"price": price,
85+
"ema_price": ema_price,
86+
"update_data": update_data,
87+
}
88+
price_feeds.append(price_feed)
89+
90+
return price_feeds
91+
92+
async def get_pyth_prices_latest(self, feedIds: list[str], version=2) -> list[PriceFeed]:
6593
"""
6694
Queries the Hermes https endpoint for the latest price feeds for a list of Pyth feed IDs.
6795
"""
68-
url = os.path.join(self.endpoint, "latest_price_feeds?")
69-
params = {"ids[]": feedIds, "binary": "true"}
96+
if version==1:
97+
url = os.path.join(self.endpoint, "api/latest_price_feeds")
98+
params = {"ids[]": feedIds, "binary": "true"}
99+
elif version==2:
100+
url = os.path.join(self.endpoint, "v2/updates/price/latest")
101+
params = {"ids[]": feedIds, "encoding": "base64", "parsed": "true"}
102+
else:
103+
parse_unsupported_version(version)
70104

71105
data = (await self.client.get(url, params=params)).json()
72106

73-
results = []
74-
for res in data:
75-
price_feed = self.extract_price_feed(res)
76-
results.append(price_feed)
107+
if version==1:
108+
results = []
109+
for res in data:
110+
price_feed = self.extract_price_feed_v1(res)
111+
results.append(price_feed)
112+
elif version==2:
113+
results = self.extract_price_feed_v2(data)
77114

78115
return results
79116

80-
async def get_pyth_price_at_time(self, feed_id: str, timestamp: int) -> PriceFeed:
117+
async def get_pyth_price_at_time(self, feed_id: str, timestamp: int, version=2) -> PriceFeed:
81118
"""
82119
Queries the Hermes https endpoint for the price feed for a Pyth feed ID at a given timestamp.
83120
"""
84-
url = os.path.join(self.endpoint, "get_price_feed")
85-
params = {"id": feed_id, "publish_time": timestamp, "binary": "true"}
121+
if version==1:
122+
url = os.path.join(self.endpoint, "api/get_price_feed")
123+
params = {"id": feed_id, "publish_time": timestamp, "binary": "true"}
124+
elif version==2:
125+
url = os.path.join(self.endpoint, f"v2/updates/price/{timestamp}")
126+
params = {"ids[]": [feed_id], "encoding": "base64", "parsed": "true"}
127+
else:
128+
parse_unsupported_version(version)
86129

87130
data = (await self.client.get(url, params=params)).json()
88131

89-
price_feed = self.extract_price_feed(data)
132+
if version==1:
133+
price_feed = self.extract_price_feed_v1(data)
134+
elif version==2:
135+
price_feed = self.extract_price_feed_v2(data)[0]
90136

91137
return price_feed
92138

93-
async def get_all_prices(self) -> dict[str, PriceFeed]:
139+
async def get_all_prices(self, version=2) -> dict[str, PriceFeed]:
94140
"""
95141
Queries the Hermes http endpoint for the latest price feeds for all feed IDs in the class object.
96142
97143
There are limitations on the number of feed IDs that can be queried at once, so this function queries the feed IDs in batches.
98144
"""
99145
pyth_prices_latest = []
100146
i = 0
101-
batch_size = 100
102-
while len(self.feed_ids[i : i + batch_size]) > 0:
147+
while len(self.feed_ids[i : i + self.feed_batch_size]) > 0:
103148
pyth_prices_latest += await self.get_pyth_prices_latest(
104-
self.feed_ids[i : i + batch_size]
149+
self.feed_ids[i : i + self.feed_batch_size],
150+
version=version,
105151
)
106-
i += batch_size
152+
i += self.feed_batch_size
107153

108154
return dict([(feed['feed_id'], feed) for feed in pyth_prices_latest])
109155

110-
async def ws_pyth_prices(self):
156+
async def ws_pyth_prices(self, version=1):
111157
"""
112158
Opens a websocket connection to Hermes for latest prices for all feed IDs in the class object.
113159
"""
114-
import json
115-
116-
import websockets
160+
if version != 1:
161+
parse_unsupported_version(version)
117162

118163
async with websockets.connect(self.ws_endpoint) as ws:
119164
while True:
@@ -139,30 +184,32 @@ async def ws_pyth_prices(self):
139184
feed_id = msg["price_feed"]["id"]
140185
new_feed = msg["price_feed"]
141186

142-
self.prices_dict[feed_id] = self.extract_price_feed(new_feed)
187+
self.prices_dict[feed_id] = self.extract_price_feed_v1(new_feed)
143188

144-
except:
145-
raise Exception("Error in price_update message", msg)
189+
except Exception as e:
190+
raise Exception(f"Error in price_update message: {msg}") from e
146191

147192

148193
async def main():
149194
hermes_client = HermesClient([])
150195
feed_ids = await hermes_client.get_price_feed_ids()
151196
feed_ids_rel = feed_ids[:50]
197+
version = 2
152198

153199
hermes_client.add_feed_ids(feed_ids_rel)
154-
155-
prices_latest = await hermes_client.get_pyth_prices_latest(feed_ids_rel)
200+
201+
prices_latest = await hermes_client.get_pyth_prices_latest(feed_ids[:50], version=version)
156202

157203
try:
158-
price_at_time = await hermes_client.get_pyth_price_at_time(feed_ids[0], 1_700_000_000)
204+
price_at_time = await hermes_client.get_pyth_price_at_time(feed_ids[0], 1_700_000_000, version=version)
205+
print(price_at_time)
159206
except Exception as e:
160207
print(f"Error in get_pyth_price_at_time, {e}")
161208

162-
all_prices = await hermes_client.get_all_prices()
209+
all_prices = await hermes_client.get_all_prices(version=version)
163210

164211
print("Starting web socket...")
165-
ws_call = hermes_client.ws_pyth_prices()
212+
ws_call = hermes_client.ws_pyth_prices(version=version)
166213
asyncio.create_task(ws_call)
167214

168215
while True:

tests/test_hermes.py

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,107 @@
1+
import pytest
2+
3+
from pytest_mock import MockerFixture
4+
5+
from mock import AsyncMock
6+
17
from pythclient.hermes import HermesClient, PriceFeed
28

3-
BTC_ID = "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" # BTC/USD
4-
ETH_ID = "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace" # ETH/USD
9+
@pytest.fixture
10+
def feed_ids():
11+
return ["e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43"]
12+
13+
@pytest.fixture
14+
def hermes_client(feed_ids):
15+
return HermesClient(feed_ids)
16+
17+
@pytest.fixture
18+
def data_v1():
19+
return {
20+
"ema_price": {
21+
"conf": "509500001",
22+
"expo": -8,
23+
"price": "2920679499999",
24+
"publish_time": 1708363256
25+
},
26+
"id": "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43",
27+
"metadata": {
28+
"emitter_chain": 26,
29+
"prev_publish_time": 1708363256,
30+
"price_service_receive_time": 1708363256,
31+
"slot": 85480034
32+
},
33+
"price": {
34+
"conf": "509500001",
35+
"expo": -8,
36+
"price": "2920679499999",
37+
"publish_time": 1708363256
38+
},
39+
"vaa": "UE5BVQEAAAADuAEAAAADDQC1H7meY5fTed0FsykIb8dt+7nKpbuzfvU2DplDi+dcUl8MC+UIkS65+rkiq+zmNBxE2gaxkBkjdIicZ/fBo+X7AAEqp+WtlWb84np8jJfLpuQ2W+l5KXTigsdAhz5DyVgU3xs+EnaIZxBwcE7EKzjMam+V9rlRy0CGsiQ1kjqqLzfAAQLsoVO0Vu5gVmgc8XGQ7xYhoz36rsBgMjG+e3l/B01esQi/KzPuBf/Ar8Sg5aSEOvEU0muSDb+KIr6d8eEC+FtcAAPZEaBSt4ysXVL84LUcJemQD3SiG30kOfUpF8o7/wI2M2Jf/LyCsbKEQUyLtLbZqnJBSfZJR5AMsrnHDqngMLEGAAY4UDG9GCpRuPvg8hOlsrXuPP3zq7yVPqyG0SG+bNo8rEhP5b1vXlHdG4bZsutX47d5VZ6xnFROKudx3T3/fnWUAQgAU1+kUFc3e0ZZeX1dLRVEryNIVyxMQIcxWwdey+jlIAYowHRM0fJX3Scs80OnT/CERwh5LMlFyU1w578NqxW+AQl2E/9fxjgUTi8crOfDpwsUsmOWw0+Q5OUGhELv/2UZoHAjsaw9OinWUggKACo4SdpPlHYldoWF+J2yGWOW+F4iAQre4c+ocb6a9uSWOnTldFkioqhd9lhmV542+VonCvuy4Tu214NP+2UNd/4Kk3KJCf3iziQJrCBeLi1cLHdLUikgAQtvRFR/nepcF9legl+DywAkUHi5/1MNjlEQvlHyh2XbMiS85yu7/9LgM6Sr+0ukfZY5mSkOcvUkpHn+T+Nw/IrQAQ7lty5luvKUmBpI3ITxSmojJ1aJ0kj/dc0ZcQk+/qo0l0l3/eRLkYjw5j+MZKA8jEubrHzUCke98eSoj8l08+PGAA+DAKNtCwNZe4p6J1Ucod8Lo5RKFfA84CPLVyEzEPQFZ25U9grUK6ilF4GhEia/ndYXLBt3PGW3qa6CBBPM7rH3ABGAyYEtUwzB4CeVedA5o6cKpjRkIebqDNSOqltsr+w7kXdfFVtsK2FMGFZNt5rbpIR+ppztoJ6eOKHmKmi9nQ99ARKkTxRErOs9wJXNHaAuIRV38o1pxRrlQRzGsRuKBqxcQEpC8OPFpyKYcp6iD5l7cO/gRDTamLFyhiUBwKKMP07FAWTEJv8AAAAAABrhAfrtrFhR4yubI7X5QRqMK6xKrj7U3XuBHdGnLqSqcQAAAAAAGp0GAUFVV1YAAAAAAAUYUmIAACcQBsfKUtr4PgZbIXRxRESU79PjE4IBAFUA5i32yLSoX+GmfbRNwS3l2zMPesZrctxliv7fD0pBW0MAAAKqqMJFwAAAAAAqE/NX////+AAAAABkxCb7AAAAAGTEJvoAAAKqIcWxYAAAAAAlR5m4CP/mPsh1IezjYpDlJ4GRb5q4fTs2LjtyO6M0XgVimrIQ4kSh1qg7JKW4gbGkyRntVFR9JO/GNd3FPDit0BK6M+JzXh/h12YNCz9wxlZTvXrNtWNbzqT+91pvl5cphhSPMfAHyEzTPaGR9tKDy9KNu56pmhaY32d2vfEWQmKo22guegeR98oDxs67MmnUraco46a3zEnac2Bm80pasUgMO24="
40+
}
41+
42+
@pytest.fixture
43+
def data_v2():
44+
return {
45+
"binary": {
46+
"encoding": "hex",
47+
"data": [
48+
"504e41550100000003b801000000030d014016474bab1868acfe943cdcd3cf7a8b7ccfaf6f2a31870694d11c441505d0552a42f57df50093df73eca16fad7ae3d768b0dd0e64dbaf71579fd5d05c46a5f20002098e46154c00ee17e878295edaca5decd18f7a1e9a1f0576ca090219f350118d1a4a0cc94b853c8ae1d5064439e719c953e61450745cf10086c37ec93d878b610003edf89d49fe5bb035d3cab5f3960ca5c7be37073b6680afb0f154ec684990923330f6db1fced4680dcfce8664c9d757fe2e8ca84aec8950004371ab794979db7101068a0231af6701f5fbfe55ac7dd31d640dd17f2fa92a10450d7a6e5db03c7c1f90131452ed1e3290fbbf00bc8528f616e81771460b2c307e02db811a84545180620107ab6ea34d72541f44cf34c8e919b9ef336eef9774ee4cf3d5c7cc71f5f90e49d23a05878e2e278402aff8217df84f9ce3ae782c389b3230d09e9e66fada355d6600084018b5993c68c4d616a570925e63a7c82c5444aee9a0f6153bd724e0755d3086374c9cf4e6ec2f08ab9c914b4cd3868e250ad4f946219cc2af0a31936cd38147000a079d8fb93db9c82263556dfd768b6173da40d35ea4691d21de59cf207381b5a05cb693fd4a75cb2b190c0270f2ddc14335adca66bcd5a634bf316a4385e97250010bf6dfa12e7820c58514c74ec21029d5c11f98a586743b2da9d2e20d8d78b44bd3730af5c6428c1ad865cb9d94ee795d059b3b51bb1e7bc8f81d52e5db18167648010c8558ac8aefd43cf489bce260afaee270d36fd1a34923439261fc8220cb33f30521cfefebfe0d7cf21d3aaa60c9149f8ab085c90b0509ad2850efe01fc618ccec010d6bc67036011a75277ca476ca1f4d962ca0d861805a94c6353ad0ff6ae17263bc5401e7d7ee3f3010f77c6349ff264c4185b167f32108c7de9743f7a258c62d03000e63f823f4b8f2cb1d158aac8f7ba0e45227b6d99106831a50729825bf8b97969503f55bc33778ef6c21e696a99d304b72c9e5ca3941dd178a7fc5367aed7d0e00010f22ccd76becc94aec99ff0bb1fce128cb25644268c65ac8f2bf5fe357910942381e184a62e8a768d5be200e21e40a34047a6e5cd981d2380de7eb4aa46a15ce0a00127957a07e69f8af6f8752a09f24dde0d43277c80d3bc24f09a281e5e68878d0ea0445b356257e25e80422ddff2f799bb732eafdeee43fc14c21d4eda349a547010165d38df800000000001ae101faedac5851e32b9b23b5f9411a8c2bac4aae3ed4dd7b811dd1a72ea4aa7100000000027a3abd0141555756000000000007823fd000002710b939e515de35dd00cf7feaba6be6aed77c83e09901005500e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43000004b868a1a543000000009ea4861cfffffff80000000065d38df80000000065d38df7000004bcd90bec4000000000c41abcc80ab559eef775bd945c821d89ceba075f3c60f2dba713f2f7ed0d210ea03ee4bead9c9b6ffd8fff45f0826e6950c44a8a7e0eac9b5bc1f2bdf276965107fc612f72a05bd37ca85017dc13b01fa5d434887f33527d87c34f1caf4ed69501a6972959e7faf96a6bc43c0d08e2b1a095c50ef6609bf81b7661102f69acb46430115e301f1ebda0f008438e31564240e1cbc9092db20b73bfc8dd832b6467fd242f0043a167ccafbc0ba479d38be012ad1d75f35e2681754e78e1f10096a55f65512fe381238a67ffce0970"
49+
]
50+
},
51+
"parsed": [
52+
{
53+
"id": "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43",
54+
"price": {
55+
"price": "5190075917635",
56+
"conf": "2661582364",
57+
"expo": -8,
58+
"publish_time": 1708363256
59+
},
60+
"ema_price": {
61+
"price": "5209141800000",
62+
"conf": "3290086600",
63+
"expo": -8,
64+
"publish_time": 1708363256
65+
},
66+
"metadata": {
67+
"slot": 125976528,
68+
"proof_available_time": 1708363257,
69+
"prev_publish_time": 1708363255
70+
}
71+
}
72+
]
73+
}
74+
75+
@pytest.fixture
76+
def mock_get_price_feed_ids(mocker: MockerFixture):
77+
async_mock = AsyncMock()
78+
mocker.patch('pythclient.hermes.HermesClient.get_price_feed_ids', side_effect=async_mock)
79+
return async_mock
80+
81+
@pytest.mark.asyncio
82+
async def test_hermes_add_feed_ids(hermes_client: HermesClient):
83+
mock_get_price_feed_ids.return_value = ["ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"]
84+
85+
feed_ids = hermes_client.get_price_feed_ids()
86+
87+
feed_ids_pre = hermes_client.feed_ids
88+
pending_feed_ids_pre = hermes_client.pending_feed_ids
89+
90+
hermes_client.add_feed_ids(feed_ids)
91+
92+
assert hermes_client.feed_ids == list(set(feed_ids_pre + feed_ids))
93+
assert hermes_client.pending_feed_ids == list(set(pending_feed_ids_pre + feed_ids))
94+
95+
596

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])
97+
def test_hermes_extract_price_feed_v1(hermes_client: HermesClient, data_v1: dict):
98+
price_feed = hermes_client.extract_price_feed_v1(data_v1)
1099

11-
all_prices = await hermes_client.get_all_prices()
100+
assert isinstance(price_feed, dict)
101+
assert set(price_feed.keys()) == set(PriceFeed.__annotations__.keys())
102+
103+
def test_hermes_extract_price_feed_v2(data_v2: dict):
104+
price_feed = hermes_client.extract_price_feed_v2(data_v2)
12105

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())
106+
assert isinstance(price_feed, dict)
107+
assert set(price_feed.keys()) == set(PriceFeed.__annotations__.keys())

0 commit comments

Comments
 (0)