Skip to content

Add aggregate_price_status which takes care of becoming stale #21

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 16, 2022
Merged
Show file tree
Hide file tree
Changes from 3 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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Install the library:

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

```
```python
from pythclient.pythclient import PythClient
from pythclient.pythaccounts import PythPriceAccount
from pythclient.utils import get_key
Expand All @@ -34,6 +34,7 @@ async with PythClient(
for _, pr in prices.items():
print(
pr.price_type,
await pr.get_aggregate_price_status(),
pr.aggregate_price,
"p/m",
pr.aggregate_price_confidence_interval,
Expand All @@ -44,11 +45,11 @@ This code snippet lists the products on pyth and the price for each product. Sam

```
{'symbol': 'Crypto.ETH/USD', 'asset_type': 'Crypto', 'quote_currency': 'USD', 'description': 'ETH/USD', 'generic_symbol': 'ETHUSD', 'base': 'ETH'}
PythPriceType.PRICE 4390.286 p/m 2.4331
PythPriceType.PRICE PythPriceStatus.TRADING 4390.286 p/m 2.4331
{'symbol': 'Crypto.SOL/USD', 'asset_type': 'Crypto', 'quote_currency': 'USD', 'description': 'SOL/USD', 'generic_symbol': 'SOLUSD', 'base': 'SOL'}
PythPriceType.PRICE 192.27550000000002 p/m 0.0485
PythPriceType.PRICE PythPriceStatus.TRADING 192.27550000000002 p/m 0.0485
{'symbol': 'Crypto.SRM/USD', 'asset_type': 'Crypto', 'quote_currency': 'USD', 'description': 'SRM/USD', 'generic_symbol': 'SRMUSD', 'base': 'SRM'}
PythPriceType.PRICE 4.23125 p/m 0.0019500000000000001
PythPriceType.PRICE PythPriceStatus.UNKNOWN 4.23125 p/m 0.0019500000000000001
...
```

Expand Down
8 changes: 6 additions & 2 deletions examples/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pythclient.pythclient import PythClient # noqa
from pythclient.ratelimit import RateLimit # noqa
from pythclient.pythaccounts import PythPriceAccount # noqa
from pythclient.pythaccounts import PythPriceAccount, PythPriceStatus # noqa
from pythclient.utils import get_key # noqa

logger.enable("pythclient")
Expand Down Expand Up @@ -50,10 +50,12 @@ async def main():
prices = await p.get_prices()
for _, pr in prices.items():
all_prices.append(pr)
price_status: PythPriceStatus = await pr.get_aggregate_price_status()
print(
pr.key,
pr.product_account_key,
pr.price_type,
price_status,
pr.aggregate_price,
"p/m",
pr.aggregate_price_confidence_interval,
Expand Down Expand Up @@ -83,14 +85,16 @@ async def main():
pr = update_task.result()
if isinstance(pr, PythPriceAccount):
assert pr.product
price_status: PythPriceStatus = await pr.get_aggregate_price_status()
print(
pr.product.symbol,
pr.price_type,
price_status,
pr.aggregate_price,
"p/m",
pr.aggregate_price_confidence_interval,
)
break
break

print("Unsubscribing...")
if use_program:
Expand Down
11 changes: 9 additions & 2 deletions examples/read_one_price_feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import asyncio

from pythclient.pythaccounts import PythPriceAccount
from pythclient.pythaccounts import PythPriceAccount, PythPriceStatus
from pythclient.solana import SolanaClient, SolanaPublicKey, SOLANA_DEVNET_HTTP_ENDPOINT, SOLANA_DEVNET_WS_ENDPOINT

async def get_price():
Expand All @@ -12,7 +12,14 @@ async def get_price():
price: PythPriceAccount = PythPriceAccount(account_key, solana_client)

await price.update()

price_status: PythPriceStatus = await price.get_aggregate_price_status()
# Sample output: "DOGE/USD is 0.141455 ± 7.4e-05"
print("DOGE/USD is", price.aggregate_price, "±", price.aggregate_price_confidence_interval)
if price_status == PythPriceStatus.TRADING:
print("DOGE/USD is", price.aggregate_price, "±", price.aggregate_price_confidence_interval)
else:
print("Price is not valid now. Status is", price_status)

await solana_client.close()

asyncio.run(get_price())
15 changes: 14 additions & 1 deletion pythclient/pythaccounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from loguru import logger

from . import exceptions
from .solana import SolanaPublicKey, SolanaPublicKeyOrStr, SolanaClient, SolanaAccount
from .solana import SolanaCommitment, SolanaPublicKey, SolanaPublicKeyOrStr, SolanaClient, SolanaAccount


_MAGIC = 0xA1B2C3D4
Expand All @@ -17,6 +17,7 @@
_SUPPORTED_VERSIONS = set((_VERSION_1, _VERSION_2))
_ACCOUNT_HEADER_BYTES = 16 # magic + version + type + size, u32 * 4
_NULL_KEY_BYTES = b'\x00' * SolanaPublicKey.LENGTH
MAX_SLOT_DIFFERENCE = 25


class PythAccountType(Enum):
Expand Down Expand Up @@ -501,6 +502,18 @@ def aggregate_price_confidence_interval(self) -> Optional[float]:
"""the aggregate price confidence interval"""
return self.aggregate_price_info and self.aggregate_price_info.confidence_interval

async def get_aggregate_price_status(self, commitment: str = SolanaCommitment.CONFIRMED) -> Optional[PythPriceStatus]:
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 it would be better to pass in the current slot as an argument. If someone has a list of accounts and they want to get the status of each one, it will be expensive to query the API for the current slot multiple times. Also, it means that they can't get the status of all the accounts at a single instant in time, because the API might return different slots each time it is queried.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree, but on the other hand it'll become a bit harder for people to use it.
I might create another normal (not async) function for it.

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 it would be reasonable to have a non-async version where the caller passes in the slot, and also an async version that calls the non-async version.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I removed async entirely as I realized using the provided slot when object is created is more efficient.

"""the aggregate price status"""

if self.aggregate_price_info:
current_slot = await self.solana.get_commitment_slot(commitment)

if self.aggregate_price_info.price_status == PythPriceStatus.TRADING and \
current_slot - self.aggregate_price_info.slot > MAX_SLOT_DIFFERENCE:
return PythPriceStatus.UNKNOWN

return self.aggregate_price_info.price_status

def update_from(self, buffer: bytes, *, version: int, offset: int = 0) -> None:
"""
Update the data in this price account from the given buffer.
Expand Down
31 changes: 31 additions & 0 deletions tests/test_price_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
import base64
from dataclasses import asdict

from pytest_mock import MockerFixture
from mock import AsyncMock

from pythclient.pythaccounts import (
MAX_SLOT_DIFFERENCE,
PythPriceAccount,
PythPriceType,
PythPriceStatus,
Expand Down Expand Up @@ -64,6 +68,13 @@ def price_account(solana_client: SolanaClient) -> PythPriceAccount:
)


@pytest.fixture()
def mock_get_commitment_slot(mocker: MockerFixture) -> AsyncMock:
async_mock = AsyncMock()
mocker.patch('pythclient.solana.SolanaClient.get_commitment_slot', side_effect=async_mock)
return async_mock


def test_price_account_update_from(price_account_bytes: bytes, price_account: PythPriceAccount):
price_account.update_from(buffer=price_account_bytes, version=2, offset=0)

Expand Down Expand Up @@ -149,3 +160,23 @@ def test_price_account_agregate_price(
):
price_account.update_from(buffer=price_account_bytes, version=2, offset=0)
assert price_account.aggregate_price == 707.125

@pytest.mark.asyncio
async def test_price_account_get_aggregate_price_status_still_trading(
price_account_bytes: bytes, price_account: PythPriceAccount, mock_get_commitment_slot: AsyncMock
):
price_account.update_from(buffer=price_account_bytes, version=2, offset=0)
mock_get_commitment_slot.return_value = price_account.aggregate_price_info.slot + MAX_SLOT_DIFFERENCE

price_status = await price_account.get_aggregate_price_status()
assert price_status == PythPriceStatus.TRADING

@pytest.mark.asyncio
async def test_price_account_get_aggregate_price_status_got_stale(
price_account_bytes: bytes, price_account: PythPriceAccount, mock_get_commitment_slot: AsyncMock
):
price_account.update_from(buffer=price_account_bytes, version=2, offset=0)
mock_get_commitment_slot.return_value = price_account.aggregate_price_info.slot + MAX_SLOT_DIFFERENCE + 1

price_status = await price_account.get_aggregate_price_status()
assert price_status == PythPriceStatus.UNKNOWN