Skip to content

Commit 1e43edb

Browse files
authored
Add aggregate_price_status which takes care of becoming stale (#21)
Adds `aggregate_price_status` property in Price Account which returns the aggregate price status considering the latest fetch slot to make sure price is not stale. `get_aggregate_price_status_with_slot` is also added so users can give latest solana slot for checking that price is not stale. Additional Changes: - Price Info `slot` field is renamed to `pub_slot`: `slot` is used in other objects within the pyth client with a different meaning (fetch slot). `pub_slot` is also consistent with other clients. - `aggregate_price` and `aggregate_price_confidence_interval` will use status and return None if price is not available (status != trading). Comments have been added to guide how to get these values if needed regardless of availability. This is more consistent with our Rust client api and will prevent incautious users to rely on price if it's not available. - In solana module `get_commitment_slot` is renamed to `get_slot` to be more consistent with the rest of its interface. Also the return type is updated. - Also fixes a small bug in dump example by indenting back the `break` on ws update handling logic.
1 parent ed0ce16 commit 1e43edb

8 files changed

+102
-34
lines changed

README.md

+5-4
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Install the library:
1616

1717
You can then read the current Pyth price using the following:
1818

19-
```
19+
```python
2020
from pythclient.pythclient import PythClient
2121
from pythclient.pythaccounts import PythPriceAccount
2222
from pythclient.utils import get_key
@@ -34,6 +34,7 @@ async with PythClient(
3434
for _, pr in prices.items():
3535
print(
3636
pr.price_type,
37+
pr.aggregate_price_status,
3738
pr.aggregate_price,
3839
"p/m",
3940
pr.aggregate_price_confidence_interval,
@@ -44,11 +45,11 @@ This code snippet lists the products on pyth and the price for each product. Sam
4445

4546
```
4647
{'symbol': 'Crypto.ETH/USD', 'asset_type': 'Crypto', 'quote_currency': 'USD', 'description': 'ETH/USD', 'generic_symbol': 'ETHUSD', 'base': 'ETH'}
47-
PythPriceType.PRICE 4390.286 p/m 2.4331
48+
PythPriceType.PRICE PythPriceStatus.TRADING 4390.286 p/m 2.4331
4849
{'symbol': 'Crypto.SOL/USD', 'asset_type': 'Crypto', 'quote_currency': 'USD', 'description': 'SOL/USD', 'generic_symbol': 'SOLUSD', 'base': 'SOL'}
49-
PythPriceType.PRICE 192.27550000000002 p/m 0.0485
50+
PythPriceType.PRICE PythPriceStatus.TRADING 192.27550000000002 p/m 0.0485
5051
{'symbol': 'Crypto.SRM/USD', 'asset_type': 'Crypto', 'quote_currency': 'USD', 'description': 'SRM/USD', 'generic_symbol': 'SRMUSD', 'base': 'SRM'}
51-
PythPriceType.PRICE 4.23125 p/m 0.0019500000000000001
52+
PythPriceType.PRICE PythPriceStatus.UNKNOWN None p/m None
5253
...
5354
```
5455

examples/dump.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ async def main():
5454
pr.key,
5555
pr.product_account_key,
5656
pr.price_type,
57+
pr.aggregate_price_status,
5758
pr.aggregate_price,
5859
"p/m",
5960
pr.aggregate_price_confidence_interval,
@@ -86,11 +87,12 @@ async def main():
8687
print(
8788
pr.product.symbol,
8889
pr.price_type,
90+
pr.aggregate_price_status,
8991
pr.aggregate_price,
9092
"p/m",
9193
pr.aggregate_price_confidence_interval,
9294
)
93-
break
95+
break
9496

9597
print("Unsubscribing...")
9698
if use_program:

examples/read_one_price_feed.py

+10-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import asyncio
44

5-
from pythclient.pythaccounts import PythPriceAccount
5+
from pythclient.pythaccounts import PythPriceAccount, PythPriceStatus
66
from pythclient.solana import SolanaClient, SolanaPublicKey, SOLANA_DEVNET_HTTP_ENDPOINT, SOLANA_DEVNET_WS_ENDPOINT
77

88
async def get_price():
@@ -12,7 +12,14 @@ async def get_price():
1212
price: PythPriceAccount = PythPriceAccount(account_key, solana_client)
1313

1414
await price.update()
15-
# Sample output: "DOGE/USD is 0.141455 ± 7.4e-05"
16-
print("DOGE/USD is", price.aggregate_price, "±", price.aggregate_price_confidence_interval)
15+
16+
price_status = price.aggregate_price_status
17+
if price_status == PythPriceStatus.TRADING:
18+
# Sample output: "DOGE/USD is 0.141455 ± 7.4e-05"
19+
print("DOGE/USD is", price.aggregate_price, "±", price.aggregate_price_confidence_interval)
20+
else:
21+
print("Price is not valid now. Status is", price_status)
22+
23+
await solana_client.close()
1724

1825
asyncio.run(get_price())

pythclient/pythaccounts.py

+38-9
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
_SUPPORTED_VERSIONS = set((_VERSION_1, _VERSION_2))
1818
_ACCOUNT_HEADER_BYTES = 16 # magic + version + type + size, u32 * 4
1919
_NULL_KEY_BYTES = b'\x00' * SolanaPublicKey.LENGTH
20+
MAX_SLOT_DIFFERENCE = 25
2021

2122

2223
class PythAccountType(Enum):
@@ -364,7 +365,7 @@ class PythPriceInfo:
364365
price (int): the price
365366
confidence_interval (int): the price confidence interval
366367
price_status (PythPriceStatus): the price status
367-
slot (int): the slot time this price information was published
368+
pub_slot (int): the slot time this price information was published
368369
exponent (int): the power-of-10 order of the price
369370
"""
370371

@@ -373,7 +374,7 @@ class PythPriceInfo:
373374
raw_price: int
374375
raw_confidence_interval: int
375376
price_status: PythPriceStatus
376-
slot: int
377+
pub_slot: int
377378
exponent: int
378379

379380
price: float = field(init=False)
@@ -397,9 +398,9 @@ def deserialise(buffer: bytes, offset: int = 0, *, exponent: int) -> PythPriceIn
397398
slot (u64)
398399
"""
399400
# _ is corporate_action
400-
price, confidence_interval, price_status, _, slot = struct.unpack_from(
401+
price, confidence_interval, price_status, _, pub_slot = struct.unpack_from(
401402
"<qQIIQ", buffer, offset)
402-
return PythPriceInfo(price, confidence_interval, PythPriceStatus(price_status), slot, exponent)
403+
return PythPriceInfo(price, confidence_interval, PythPriceStatus(price_status), pub_slot, exponent)
403404

404405
def __str__(self) -> str:
405406
return f"PythPriceInfo status {self.price_status} price {self.price}"
@@ -472,7 +473,7 @@ class PythPriceAccount(PythAccount):
472473
aggregate_price_info (PythPriceInfo): the aggregate price information
473474
price_components (List[PythPriceComponent]): the price components that the
474475
aggregate price is composed of
475-
slot (int): the slot time when this account was last updated
476+
slot (int): the slot time when this account was last fetched
476477
product (Optional[PythProductAccount]): the product this price is for, if loaded
477478
"""
478479

@@ -493,13 +494,41 @@ def __init__(self, key: SolanaPublicKey, solana: SolanaClient, *, product: Optio
493494

494495
@property
495496
def aggregate_price(self) -> Optional[float]:
496-
"""the aggregate price"""
497-
return self.aggregate_price_info and self.aggregate_price_info.price
497+
"""
498+
The aggregate price. Returns None if price is not currently available.
499+
If you need the price value regardless of availability use `aggregate_price_info.price`
500+
"""
501+
if self.aggregate_price_status == PythPriceStatus.TRADING:
502+
return self.aggregate_price_info.price
503+
else:
504+
return None
498505

499506
@property
500507
def aggregate_price_confidence_interval(self) -> Optional[float]:
501-
"""the aggregate price confidence interval"""
502-
return self.aggregate_price_info and self.aggregate_price_info.confidence_interval
508+
"""
509+
The aggregate price confidence interval. Returns None if price is not currently available.
510+
If you need the confidence value regardless of availability use `aggregate_price_info.confidence_interval`
511+
"""
512+
if self.aggregate_price_status == PythPriceStatus.TRADING:
513+
return self.aggregate_price_info.confidence_interval
514+
else:
515+
return None
516+
517+
@property
518+
def aggregate_price_status(self) -> Optional[PythPriceStatus]:
519+
"""The aggregate price status."""
520+
return self.get_aggregate_price_status_with_slot(self.slot)
521+
522+
def get_aggregate_price_status_with_slot(self, slot: int) -> Optional[PythPriceStatus]:
523+
"""
524+
Gets the aggregate price status given a solana slot.
525+
You might consider using this function with the latest solana slot to make sure the price has not gone stale.
526+
"""
527+
if self.aggregate_price_info.price_status == PythPriceStatus.TRADING and \
528+
slot - self.aggregate_price_info.pub_slot > MAX_SLOT_DIFFERENCE:
529+
return PythPriceStatus.UNKNOWN
530+
531+
return self.aggregate_price_info.price_status
503532

504533
def update_from(self, buffer: bytes, *, version: int, offset: int = 0) -> None:
505534
"""

pythclient/solana.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,10 @@ async def get_health(self) -> Union[Literal['ok'], Dict[str, Any]]:
286286
async def get_cluster_nodes(self) -> List[Dict[str, Any]]:
287287
return await self.http_send("getClusterNodes")
288288

289-
async def get_commitment_slot(
289+
async def get_slot(
290290
self,
291-
commitment: str
292-
) -> Dict[str, Any]:
291+
commitment: str = SolanaCommitment.CONFIRMED,
292+
) -> Union[int, Dict[str, Any]]:
293293
return await self.http_send(
294294
"getSlot",
295295
[{"commitment": commitment}]

tests/test_price_account.py

+34-5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from dataclasses import asdict
44

55
from pythclient.pythaccounts import (
6+
MAX_SLOT_DIFFERENCE,
67
PythPriceAccount,
78
PythPriceType,
89
PythPriceStatus,
@@ -55,15 +56,13 @@ def price_account_bytes():
5556
b'AAABAAAAAAAAANipUgYAAAAAn4M0eBAAAABh7UgCAAAAAAEAAAAAAAAA2alSBgAAAAA='
5657
))
5758

58-
5959
@pytest.fixture
6060
def price_account(solana_client: SolanaClient) -> PythPriceAccount:
6161
return PythPriceAccount(
6262
key=SolanaPublicKey("5ALDzwcRJfSyGdGyhP3kP628aqBNHZzLuVww7o9kdspe"),
6363
solana=solana_client,
6464
)
6565

66-
6766
def test_price_account_update_from(price_account_bytes: bytes, price_account: PythPriceAccount):
6867
price_account.update_from(buffer=price_account_bytes, version=2, offset=0)
6968

@@ -81,7 +80,7 @@ def test_price_account_update_from(price_account_bytes: bytes, price_account: Py
8180
"raw_price": 70712500000,
8281
"raw_confidence_interval": 36630500,
8382
"price_status": PythPriceStatus.TRADING,
84-
"slot": 106080731,
83+
"pub_slot": 106080731,
8584
"exponent": -8,
8685
"price": 707.125,
8786
"confidence_interval": 0.366305,
@@ -95,7 +94,7 @@ def test_price_account_update_from(price_account_bytes: bytes, price_account: Py
9594
"raw_price": 70709500000,
9695
"raw_confidence_interval": 21500000,
9796
"price_status": PythPriceStatus.TRADING,
98-
"slot": 106080728,
97+
"pub_slot": 106080728,
9998
"exponent": -8,
10099
"price": 707.095,
101100
"confidence_interval": 0.215,
@@ -104,7 +103,7 @@ def test_price_account_update_from(price_account_bytes: bytes, price_account: Py
104103
"raw_price": 70709500000,
105104
"raw_confidence_interval": 21500000,
106105
"price_status": PythPriceStatus.TRADING,
107-
"slot": 106080729,
106+
"pub_slot": 106080729,
108107
"exponent": -8,
109108
"price": 707.095,
110109
"confidence_interval": 0.215,
@@ -141,11 +140,41 @@ def test_price_account_agregate_conf_interval(
141140
price_account_bytes: bytes, price_account: PythPriceAccount,
142141
):
143142
price_account.update_from(buffer=price_account_bytes, version=2, offset=0)
143+
price_account.slot = price_account.aggregate_price_info.pub_slot
144144
assert price_account.aggregate_price_confidence_interval == 0.366305
145145

146146

147147
def test_price_account_agregate_price(
148148
price_account_bytes: bytes, price_account: PythPriceAccount,
149149
):
150150
price_account.update_from(buffer=price_account_bytes, version=2, offset=0)
151+
price_account.slot = price_account.aggregate_price_info.pub_slot
151152
assert price_account.aggregate_price == 707.125
153+
154+
def test_price_account_unknown_status(
155+
price_account_bytes: bytes, price_account: PythPriceAccount,
156+
):
157+
price_account.update_from(buffer=price_account_bytes, version=2, offset=0)
158+
price_account.slot = price_account.aggregate_price_info.pub_slot
159+
price_account.aggregate_price_info.price_status = PythPriceStatus.UNKNOWN
160+
161+
assert price_account.aggregate_price is None
162+
assert price_account.aggregate_price_confidence_interval is None
163+
164+
def test_price_account_get_aggregate_price_status_still_trading(
165+
price_account_bytes: bytes, price_account: PythPriceAccount
166+
):
167+
price_account.update_from(buffer=price_account_bytes, version=2, offset=0)
168+
price_account.slot = price_account.aggregate_price_info.pub_slot + MAX_SLOT_DIFFERENCE
169+
170+
price_status = price_account.aggregate_price_status
171+
assert price_status == PythPriceStatus.TRADING
172+
173+
def test_price_account_get_aggregate_price_status_got_stale(
174+
price_account_bytes: bytes, price_account: PythPriceAccount
175+
):
176+
price_account.update_from(buffer=price_account_bytes, version=2, offset=0)
177+
price_account.slot = price_account.aggregate_price_info.pub_slot + MAX_SLOT_DIFFERENCE + 1
178+
179+
price_status = price_account.aggregate_price_status
180+
assert price_status == PythPriceStatus.UNKNOWN

tests/test_price_component.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,14 @@ def price_component() -> PythPriceComponent:
2121
'raw_price': 62931500000,
2222
'raw_confidence_interval': 16500000,
2323
'price_status': PythPriceStatus.TRADING,
24-
'slot': 105886163,
24+
'pub_slot': 105886163,
2525
'exponent': exponent,
2626
})
2727
latest_price = PythPriceInfo(**{
2828
'raw_price': 62931500000,
2929
'raw_confidence_interval': 16500000,
3030
'price_status': PythPriceStatus.TRADING,
31-
'slot': 105886164,
31+
'pub_slot': 105886164,
3232
'exponent': exponent,
3333
})
3434
return PythPriceComponent(

tests/test_price_info.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def price_info_trading():
1010
raw_price=59609162000,
1111
raw_confidence_interval=43078500,
1212
price_status=PythPriceStatus.TRADING,
13-
slot=105367617,
13+
pub_slot=105367617,
1414
exponent=-8,
1515
)
1616

@@ -21,7 +21,7 @@ def price_info_trading_bytes():
2121

2222

2323
@pytest.mark.parametrize(
24-
"raw_price,raw_confidence_interval,price_status,slot,exponent,price,confidence_interval",
24+
"raw_price,raw_confidence_interval,price_status,pub_slot,exponent,price,confidence_interval",
2525
[
2626
(
2727
1234567890,
@@ -42,7 +42,7 @@ def test_price_info(
4242
raw_price,
4343
raw_confidence_interval,
4444
price_status,
45-
slot,
45+
pub_slot,
4646
exponent,
4747
price,
4848
confidence_interval,
@@ -51,7 +51,7 @@ def test_price_info(
5151
raw_price=raw_price,
5252
raw_confidence_interval=raw_confidence_interval,
5353
price_status=price_status,
54-
slot=slot,
54+
pub_slot=pub_slot,
5555
exponent=exponent,
5656
)
5757
for key, actual_value in asdict(actual).items():
@@ -62,7 +62,7 @@ def test_price_info_iter(
6262
raw_price,
6363
raw_confidence_interval,
6464
price_status,
65-
slot,
65+
pub_slot,
6666
exponent,
6767
price,
6868
confidence_interval,
@@ -72,15 +72,15 @@ def test_price_info_iter(
7272
raw_price=raw_price,
7373
raw_confidence_interval=raw_confidence_interval,
7474
price_status=price_status,
75-
slot=slot,
75+
pub_slot=pub_slot,
7676
exponent=exponent,
7777
)
7878
)
7979
expected = {
8080
"raw_price": raw_price,
8181
"raw_confidence_interval": raw_confidence_interval,
8282
"price_status": price_status,
83-
"slot": slot,
83+
"pub_slot": pub_slot,
8484
"exponent": exponent,
8585
"price": price,
8686
"confidence_interval": confidence_interval,

0 commit comments

Comments
 (0)