From e6762c0415b0f28a31ed57e167d79cf8d653ac50 Mon Sep 17 00:00:00 2001 From: 0xevolve Date: Fri, 22 Nov 2024 11:42:09 +0100 Subject: [PATCH] feat: add USDPLUS support (#228) * feat: usdplus and some refacto * fix: ekubo hopping w/ usdplus + defillama fetcher * Update fetcher_test.py * Update config.example.yaml * fix: cleanup --- infra/price-pusher/config/config.mainnet.yaml | 4 +- infra/price-pusher/config/config.sepolia.yaml | 2 +- .../common/fetchers/fetchers/__init__.py | 2 +- .../common/fetchers/fetchers/defillama.py | 6 +- .../common/fetchers/fetchers/dexscreener.py | 4 +- .../common/fetchers/fetchers/ekubo.py | 31 ++-- .../fetchers/{gecko.py => geckoterminal.py} | 3 +- pragma-sdk/pragma_sdk/supported_assets.yaml | 7 +- pragma-sdk/tests/fetcher_test.py | 133 ++++++++++++++++++ price-pusher/config/config.example.yaml | 1 + 10 files changed, 170 insertions(+), 23 deletions(-) rename pragma-sdk/pragma_sdk/common/fetchers/fetchers/{gecko.py => geckoterminal.py} (99%) create mode 100644 pragma-sdk/tests/fetcher_test.py diff --git a/infra/price-pusher/config/config.mainnet.yaml b/infra/price-pusher/config/config.mainnet.yaml index 0342abc0..e91cb2e2 100644 --- a/infra/price-pusher/config/config.mainnet.yaml +++ b/infra/price-pusher/config/config.mainnet.yaml @@ -20,7 +20,7 @@ - ZEND/USD - NSTR/USD - EKUBO/USD - - BROTHER/USD + - BROTHER/USDPLUS time_difference: 600 price_deviation: 0.025 @@ -30,5 +30,5 @@ - ETH/USD - BTC/USDT - ETH/USDT - time_difference: 600 + time_difference: 3000 price_deviation: 0.05 \ No newline at end of file diff --git a/infra/price-pusher/config/config.sepolia.yaml b/infra/price-pusher/config/config.sepolia.yaml index 5b90aafe..0f92ace6 100644 --- a/infra/price-pusher/config/config.sepolia.yaml +++ b/infra/price-pusher/config/config.sepolia.yaml @@ -20,7 +20,7 @@ - ZEND/USD - NSTR/USD - EKUBO/USD - - BROTHER/USD + - BROTHER/USDPLUS - LUSD/USD time_difference: 120 price_deviation: 0.005 diff --git a/pragma-sdk/pragma_sdk/common/fetchers/fetchers/__init__.py b/pragma-sdk/pragma_sdk/common/fetchers/fetchers/__init__.py index b6d192d6..a2ab737c 100644 --- a/pragma-sdk/pragma_sdk/common/fetchers/fetchers/__init__.py +++ b/pragma-sdk/pragma_sdk/common/fetchers/fetchers/__init__.py @@ -3,7 +3,7 @@ from pragma_sdk.common.fetchers.fetchers.bybit import BybitFetcher from pragma_sdk.common.fetchers.fetchers.coinbase import CoinbaseFetcher from pragma_sdk.common.fetchers.fetchers.defillama import DefillamaFetcher -from pragma_sdk.common.fetchers.fetchers.gecko import GeckoTerminalFetcher +from pragma_sdk.common.fetchers.fetchers.geckoterminal import GeckoTerminalFetcher from pragma_sdk.common.fetchers.fetchers.huobi import HuobiFetcher from pragma_sdk.common.fetchers.fetchers.indexcoop import IndexCoopFetcher from pragma_sdk.common.fetchers.fetchers.kucoin import KucoinFetcher diff --git a/pragma-sdk/pragma_sdk/common/fetchers/fetchers/defillama.py b/pragma-sdk/pragma_sdk/common/fetchers/fetchers/defillama.py index 6e44e907..9ef3bf2b 100644 --- a/pragma-sdk/pragma_sdk/common/fetchers/fetchers/defillama.py +++ b/pragma-sdk/pragma_sdk/common/fetchers/fetchers/defillama.py @@ -15,9 +15,7 @@ class DefillamaFetcher(FetcherInterfaceT): - BASE_URL: str = ( - "https://coins.llama.fi/prices/current/coingecko:{pair_id}" "?searchWidth=15m" - ) + BASE_URL: str = "https://coins.llama.fi/prices/current/coingecko:{pair_id}" SOURCE: str = "DEFILLAMA" async def fetch_pair( @@ -28,7 +26,7 @@ async def fetch_pair( return PublisherFetchError( f"Unknown price pair, do not know how to query Coingecko for {pair.base_currency.id}" ) - if pair.quote_currency.id != "USD": + if pair.quote_currency.id not in ("USD", "USDPLUS"): return await self.operate_usd_hop(pair, session) url = self.format_url(pair) diff --git a/pragma-sdk/pragma_sdk/common/fetchers/fetchers/dexscreener.py b/pragma-sdk/pragma_sdk/common/fetchers/fetchers/dexscreener.py index f917c3ac..44e5ac41 100644 --- a/pragma-sdk/pragma_sdk/common/fetchers/fetchers/dexscreener.py +++ b/pragma-sdk/pragma_sdk/common/fetchers/fetchers/dexscreener.py @@ -50,7 +50,7 @@ async def fetch_pair( NOTE: The base currency being priced must have either a starknet_address or an ethereum_address. """ - if pair.quote_currency.id != "USD": + if pair.quote_currency.id not in ("USD", "USDPLUS"): return PublisherFetchError(f"No data found for {pair} from Dexscreener") if (pair.base_currency.ethereum_address == 0) and ( pair.base_currency.starknet_address == 0 @@ -111,7 +111,7 @@ def format_url( # type: ignore[override] """ Format the URL to fetch in order to retrieve the price for a pair. """ - if pair.base_currency.ethereum_address is not None: + if pair.base_currency.ethereum_address not in (None, 0): base_address = f"{pair.base_currency.ethereum_address:#0{42}x}" else: base_address = f"{pair.base_currency.starknet_address:#0{66}x}" diff --git a/pragma-sdk/pragma_sdk/common/fetchers/fetchers/ekubo.py b/pragma-sdk/pragma_sdk/common/fetchers/fetchers/ekubo.py index 949ebe9c..636058f3 100644 --- a/pragma-sdk/pragma_sdk/common/fetchers/fetchers/ekubo.py +++ b/pragma-sdk/pragma_sdk/common/fetchers/fetchers/ekubo.py @@ -49,9 +49,7 @@ class EkuboFetcher(FetcherInterfaceT): publisher: str price_fetcher_contract: int hop_handler: HopHandler = HopHandler( - hopped_currencies={ - "USD": "USDC", - } + hopped_currencies={"USD": "USDC", "USDPLUS": "USDC"} ) def __init__( @@ -261,18 +259,29 @@ async def _adapt_back_hopped_pair( At the end, we return the original Pair before hop and the price. """ - for asset, hopped_to in self.hop_handler.hopped_currencies.items(): - if hopped_to == pair.quote_currency.id: - hop: Tuple[str, str] = (asset, hopped_to) - break + # For USDPLUS, use USD prices directly + requested_quote = self.pairs[0].quote_currency.id + lookup_quote = "USD" if requested_quote == "USDPLUS" else requested_quote - hop_quote_pair = Pair.from_tickers(pair.quote_currency.id, hop[0]) + hop_quote_pair = Pair.from_tickers(pair.quote_currency.id, lookup_quote) hop_price = hop_prices.get(hop_quote_pair) + if hop_price is None: - raise ValueError("Could not find hop price. Should never happen.") + # Try reverse pair + reverse_pair = Pair.from_tickers(lookup_quote, pair.quote_currency.id) + reverse_price = hop_prices.get(reverse_pair) + if reverse_price: + hop_price = 1 / reverse_price + else: + raise ValueError( + f"No valid hop price found for {hop_quote_pair} or {reverse_pair}" + ) + + # Create the final pair with the originally requested quote currency + new_pair = Pair.from_tickers(pair.base_currency.id, requested_quote) + final_price = price * hop_price - new_pair = Pair.from_tickers(pair.base_currency.id, hop[0]) - return (new_pair, price * hop_price) + return (new_pair, final_price) def _handle_error_status( self, status: EkuboStatus, pair: Pair diff --git a/pragma-sdk/pragma_sdk/common/fetchers/fetchers/gecko.py b/pragma-sdk/pragma_sdk/common/fetchers/fetchers/geckoterminal.py similarity index 99% rename from pragma-sdk/pragma_sdk/common/fetchers/fetchers/gecko.py rename to pragma-sdk/pragma_sdk/common/fetchers/fetchers/geckoterminal.py index 4a76ee41..91055b95 100644 --- a/pragma-sdk/pragma_sdk/common/fetchers/fetchers/gecko.py +++ b/pragma-sdk/pragma_sdk/common/fetchers/fetchers/geckoterminal.py @@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional from aiohttp import ClientSession +import aiohttp from pragma_sdk.common.configs.asset_config import AssetConfig from pragma_sdk.common.types.currency import Currency @@ -82,7 +83,7 @@ class GeckoTerminalFetcher(FetcherInterfaceT): async def fetch_pair( self, pair: Pair, session: ClientSession ) -> SpotEntry | PublisherFetchError: - if pair.quote_currency.id != "USD": + if pair.quote_currency.id not in ("USD", "USDPLUS"): return await self.operate_usd_hop(pair, session) pool = ASSET_MAPPING.get(pair.base_currency.id) if pool is None: diff --git a/pragma-sdk/pragma_sdk/supported_assets.yaml b/pragma-sdk/pragma_sdk/supported_assets.yaml index bfbfd904..1ed3933e 100644 --- a/pragma-sdk/pragma_sdk/supported_assets.yaml +++ b/pragma-sdk/pragma_sdk/supported_assets.yaml @@ -40,6 +40,11 @@ ticker: 'USD' abstract: true +- name: 'US Dollar (18 decimals)' + decimals: 18 + ticker: 'USDPLUS' + abstract: true + - name: 'Euro' decimals: 8 ticker: 'EUR' @@ -245,4 +250,4 @@ decimals: 18 ticker: 'BROTHER' coingecko_id: 'starknet-brother' - starknet_address: '0x3b405a98c9e795d427fe82cdeeeed803f221b52471e3a757574a2b4180793ee' + starknet_address: '0x03b405a98c9e795d427fe82cdeeeed803f221b52471e3a757574a2b4180793ee' diff --git a/pragma-sdk/tests/fetcher_test.py b/pragma-sdk/tests/fetcher_test.py new file mode 100644 index 00000000..856074f4 --- /dev/null +++ b/pragma-sdk/tests/fetcher_test.py @@ -0,0 +1,133 @@ +import asyncio +import argparse +import aiohttp +from datetime import datetime +from typing import Type, Optional + +from pragma_sdk.common.types.pair import Pair +from pragma_sdk.common.types.currency import Currency +from pragma_sdk.common.configs.asset_config import AssetConfig +from pragma_sdk.common.types.entry import SpotEntry +from pragma_sdk.common.exceptions import PublisherFetchError +from pragma_sdk.common.fetchers.interface import FetcherInterfaceT +from pragma_sdk.common.utils import felt_to_str + +""" +Smol script to test any fetcher easily + +e.g +``` +python fetcher_test.py DefillamaFetcher BROTHER USDPLUS +``` +""" +async def test_fetcher( + fetcher_class: Type[FetcherInterfaceT], + base_currency: str, + quote_currency: str, + api_key: Optional[str] = None, +) -> None: + """ + Test a price fetcher for a specific currency pair. + + Args: + fetcher_class: The fetcher class to test + base_currency: Base currency ticker (e.g., "BTC") + quote_currency: Quote currency ticker (e.g., "USD") + api_key: Optional API key for the fetcher + """ + # Create the currency pair + base = Currency.from_asset_config(AssetConfig.from_ticker(base_currency)) + quote = Currency.from_asset_config(AssetConfig.from_ticker(quote_currency)) + pair = Pair(base, quote) + + # Initialize the fetcher + fetcher = fetcher_class(pairs=[pair], publisher="TEST") + + # Set API key if provided + if api_key and hasattr(fetcher, "headers"): + fetcher.headers["Authorization"] = f"Bearer {api_key}" + + print(f"\nTesting {fetcher.__class__.__name__} for pair: {pair}") + print("-" * 50) + + try: + async with aiohttp.ClientSession() as session: + start_time = datetime.now() + results = await fetcher.fetch(session) + result = results[0] + end_time = datetime.now() + + if isinstance(result, SpotEntry): + print("✅ Successfully fetched price:") + print(f" Price: {result.price}") + print( + f" Human readable price: {result.price / (10 ** pair.decimals())}" + ) + print(f" Timestamp: {result.base.timestamp}") + print( + f" Human readable time: {datetime.fromtimestamp(result.base.timestamp)}" + ) + print(f" Volume: {result.volume}") + print(f" Source: {felt_to_str(result.base.source)}") + print(f" Publisher: {felt_to_str(result.base.publisher)}") + print(f" Fetch time: {(end_time - start_time).total_seconds():.3f}s") + elif isinstance(result, PublisherFetchError): + print("❌ Error fetching price:") + print(f" Error message: {str(result)}") + else: + print("❌ Unexpected result type:") + print(f" {type(result)}: {result}") + + except Exception as e: + print("❌ Exception occurred:") + print(f" {type(e).__name__}: {str(e)}") + + +def main(): + parser = argparse.ArgumentParser( + description="Test a price fetcher for a specific currency pair" + ) + parser.add_argument( + "fetcher", type=str, help="Fetcher class name (e.g., GeckoTerminalFetcher)" + ) + parser.add_argument("base", type=str, help="Base currency ticker (e.g., BTC)") + parser.add_argument("quote", type=str, help="Quote currency ticker (e.g., USD)") + parser.add_argument( + "--api-key", type=str, help="API key for the fetcher", default=None + ) + + args = parser.parse_args() + + # Import the fetcher class dynamically + try: + # This assumes the fetcher is in the same directory + # You might need to modify this to import from different locations + module_name = args.fetcher.lower() + if module_name.endswith("fetcher"): + module_name = module_name[:-7] + + fetcher_module = __import__( + f"pragma_sdk.common.fetchers.fetchers.{module_name}", + fromlist=[args.fetcher], + ) + fetcher_class = getattr(fetcher_module, args.fetcher) + + asyncio.run( + test_fetcher( + fetcher_class=fetcher_class, + base_currency=args.base, + quote_currency=args.quote, + api_key=args.api_key, + ) + ) + + except ImportError as e: + print(f"❌ Could not import fetcher class '{args.fetcher}', {e}") + except AttributeError: + print(f"❌ Could not find fetcher class '{args.fetcher}' in module") + except Exception as e: + print(f"❌ Error: {type(e).__name__}: {str(e)}") + + +if __name__ == "__main__": + main() diff --git a/price-pusher/config/config.example.yaml b/price-pusher/config/config.example.yaml index 7e421a2f..a878abcd 100644 --- a/price-pusher/config/config.example.yaml +++ b/price-pusher/config/config.example.yaml @@ -2,6 +2,7 @@ spot: - BTC/USD - ETH/USD + - BROTHER/USDPLUS time_difference: 1 price_deviation: 0.025 \ No newline at end of file