From 4367b0e0623235da289763fa515dcdf5750ccdd3 Mon Sep 17 00:00:00 2001 From: Ali Behjati Date: Thu, 27 Nov 2025 17:29:54 +0100 Subject: [PATCH 1/8] refactor: improve alert dispatch code --- pyth_observer/alert_utils.py | 18 +++++++ pyth_observer/dispatch.py | 99 ++++++++++++++++++++++++++---------- pyth_observer/event.py | 6 +-- 3 files changed, 91 insertions(+), 32 deletions(-) create mode 100644 pyth_observer/alert_utils.py diff --git a/pyth_observer/alert_utils.py b/pyth_observer/alert_utils.py new file mode 100644 index 0000000..c42cfc9 --- /dev/null +++ b/pyth_observer/alert_utils.py @@ -0,0 +1,18 @@ +""" +Utility functions for alert identification and management. +""" + +from pyth_observer.check import Check +from pyth_observer.check.publisher import PublisherState + + +def generate_alert_identifier(check: Check) -> str: + """ + Generate a unique alert identifier for a check. + This is a shared function to ensure consistency across the codebase. + """ + alert_identifier = f"{check.__class__.__name__}-{check.state().symbol}" + state = check.state() + if isinstance(state, PublisherState): + alert_identifier += f"-{state.publisher_name}" + return alert_identifier diff --git a/pyth_observer/dispatch.py b/pyth_observer/dispatch.py index 5bc3be0..698e5b4 100644 --- a/pyth_observer/dispatch.py +++ b/pyth_observer/dispatch.py @@ -3,10 +3,11 @@ import os from copy import deepcopy from datetime import datetime, timedelta -from typing import Any, Awaitable, Dict, List +from typing import Any, Awaitable, Dict, List, Optional, TypedDict from loguru import logger +from pyth_observer.alert_utils import generate_alert_identifier from pyth_observer.check import Check, State from pyth_observer.check.price_feed import PRICE_FEED_CHECKS, PriceFeedState from pyth_observer.check.publisher import PUBLISHER_CHECKS, PublisherState @@ -24,6 +25,27 @@ assert ZendutyEvent +class AlertInfo(TypedDict): + """ + Information about an open alert tracked for threshold-based alerting. + + Fields: + type: The check class name (e.g., "PublisherOfflineCheck") + window_start: ISO format datetime string marking the start of the current 5-minute window + failures: Number of failures in the current 5-minute window + last_window_failures: Number of failures in the previous 5-minute window (None if no previous window) + sent: Whether an alert has been sent for this issue + last_alert: ISO format datetime string of when the last alert was sent (None if never sent) + """ + + type: str + window_start: str + failures: int + last_window_failures: Optional[int] + sent: bool + last_alert: Optional[str] + + class Dispatch: """ Load configuration for each check/state pair, run the check, and run @@ -35,7 +57,7 @@ def __init__( ) -> None: self.config = config self.publishers = publishers - self.open_alerts: Dict[str, Any] = {} + self.open_alerts: Dict[str, AlertInfo] = {} if "ZendutyEvent" in self.config["events"]: self.open_alerts_file = os.environ["OPEN_ALERTS_FILE"] self.open_alerts = self.load_alerts() @@ -43,10 +65,17 @@ def __init__( # events cannot be stored in open_alerts as they are not JSON serializable. self.delayed_events: Dict[str, Event] = {} - def load_alerts(self) -> Dict[str, Any]: + def load_alerts(self) -> Dict[str, AlertInfo]: try: with open(self.open_alerts_file, "r") as file: - return json.load(file) + loaded = json.load(file) + # Ensure all required fields are present + for alert_id, alert in loaded.items(): + if "last_window_failures" not in alert: + alert["last_window_failures"] = None + if "last_alert" not in alert: + alert["last_alert"] = None + return loaded # type: ignore[return-value] except FileNotFoundError: return {} # Return an empty dict if the file doesn't exist @@ -74,7 +103,7 @@ async def run(self, states: List[State]) -> None: event: Event = globals()[event_type](check, context) if event_type in ["ZendutyEvent", "TelegramEvent"]: - alert_identifier = self.generate_alert_identifier(check) + alert_identifier = generate_alert_identifier(check) alert = self.open_alerts.get(alert_identifier) if alert is None: self.open_alerts[alert_identifier] = { @@ -83,9 +112,14 @@ async def run(self, states: List[State]) -> None: "failures": 1, "last_window_failures": None, "sent": False, + "last_alert": None, } else: + # Check window status before incrementing to avoid losing current run's failures + self.check_zd_alert_status(alert_identifier, current_time) alert["failures"] += 1 + # Always update delayed_events with the latest event to ensure we send + # the most recent error information when the alert is finally sent self.delayed_events[f"{event_type}-{alert_identifier}"] = event continue # Skip sending immediately for ZendutyEvent or TelegramEvent @@ -168,12 +202,6 @@ def load_config(self, check_name: str, symbol: str) -> Dict[str, Any]: return config # Zenduty Functions - def generate_alert_identifier(self, check: Check) -> str: - alert_identifier = f"{check.__class__.__name__}-{check.state().symbol}" - state = check.state() - if isinstance(state, PublisherState): - alert_identifier += f"-{state.publisher_name}" - return alert_identifier def check_zd_alert_status( self, alert_identifier: str, current_time: datetime @@ -193,16 +221,27 @@ async def process_zenduty_events(self, current_time: datetime) -> None: to_alert = [] for identifier, info in self.open_alerts.items(): + # Check window status (idempotent - safe to call multiple times) + # This handles alerts that didn't have failures in the current run self.check_zd_alert_status(identifier, current_time) check_config = self.config["checks"]["global"][info["type"]] alert_threshold = check_config.get("alert_threshold", 5) resolution_threshold = check_config.get("resolution_threshold", 3) - # Resolve the alert if raised and failed < $threshold times in the last 5m window + # Resolve the alert if raised and failed <= $threshold times in the last 5m window + # OR if the current window has low failures (for immediate resolution) resolved = False - if ( + # Check if last window had low failures + last_window_resolved = ( info["last_window_failures"] is not None and info["last_window_failures"] <= resolution_threshold - ): + ) + # Check if current window has low failures (and alert was previously sent) + current_window_resolved = ( + info["sent"] + and info["failures"] <= resolution_threshold + and info["failures"] < alert_threshold + ) + if last_window_resolved or current_window_resolved: logger.debug(f"Resolving Zenduty alert {identifier}") resolved = True @@ -223,7 +262,7 @@ async def process_zenduty_events(self, current_time: datetime) -> None: elif ( info["failures"] >= alert_threshold or (info["sent"] and not resolved) ) and ( - not info.get("last_alert") # First alert - send immediately + info["last_alert"] is None # First alert - send immediately or ( # Subsequent alerts - send at the start of each hour current_time - datetime.fromisoformat(info["last_alert"]) > timedelta(minutes=5) @@ -233,15 +272,17 @@ async def process_zenduty_events(self, current_time: datetime) -> None: logger.debug(f"Raising Zenduty alert {identifier}") self.open_alerts[identifier]["sent"] = True self.open_alerts[identifier]["last_alert"] = current_time.isoformat() - for event_type in ["ZendutyEvent", "TelegramEvent"]: - key = f"{event_type}-{identifier}" - event = self.delayed_events.get(key) - if event: - to_alert.append(event.send()) - metrics.alerts_sent_total.labels( - alert_type=info["type"], - channel=event_type.lower().replace("event", ""), - ).inc() + # Only send events for event types that are actually enabled + for event_type in self.config["events"]: + if event_type in ["ZendutyEvent", "TelegramEvent"]: + key = f"{event_type}-{identifier}" + event = self.delayed_events.get(key) + if event: + to_alert.append(event.send()) + metrics.alerts_sent_total.labels( + alert_type=info["type"], + channel=event_type.lower().replace("event", ""), + ).inc() # Send the alerts that were delayed due to thresholds await asyncio.gather(*to_alert) @@ -250,10 +291,12 @@ async def process_zenduty_events(self, current_time: datetime) -> None: for identifier in to_remove: if self.open_alerts.get(identifier): del self.open_alerts[identifier] - for event_type in ["ZendutyEvent", "TelegramEvent"]: - key = f"{event_type}-{identifier}" - if self.delayed_events.get(key): - del self.delayed_events[key] + # Only clean up delayed_events for event types that are actually enabled + for event_type in self.config["events"]: + if event_type in ["ZendutyEvent", "TelegramEvent"]: + key = f"{event_type}-{identifier}" + if self.delayed_events.get(key): + del self.delayed_events[key] metrics.update_alert_metrics(self.open_alerts) diff --git a/pyth_observer/event.py b/pyth_observer/event.py index 2e98d5b..c175cb1 100644 --- a/pyth_observer/event.py +++ b/pyth_observer/event.py @@ -10,6 +10,7 @@ from dotenv import load_dotenv from loguru import logger +from pyth_observer.alert_utils import generate_alert_identifier from pyth_observer.check import Check from pyth_observer.check.publisher import PublisherCheck, PublisherState from pyth_observer.models import Publisher @@ -164,12 +165,9 @@ async def send(self) -> None: for key, value in event_details.items(): summary += f"{key}: {value}\n" - alert_identifier = ( - f"{self.check.__class__.__name__}-{self.check.state().symbol}" - ) + alert_identifier = generate_alert_identifier(self.check) state = self.check.state() if isinstance(state, PublisherState): - alert_identifier += f"-{state.publisher_name}" symbol = ( self.check.state().symbol.replace(".", "-").replace("/", "-").lower() ) From a89a91b18a93eaf1aee67077508fccaac67965b0 Mon Sep 17 00:00:00 2001 From: Ali Behjati Date: Thu, 27 Nov 2025 17:33:48 +0100 Subject: [PATCH 2/8] refactor: refresh prices in batch this reduces the pass time from 5m to 30s --- pyth_observer/__init__.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/pyth_observer/__init__.py b/pyth_observer/__init__.py index ab3e286..3146a3a 100644 --- a/pyth_observer/__init__.py +++ b/pyth_observer/__init__.py @@ -5,7 +5,7 @@ from base58 import b58decode from loguru import logger from pythclient.market_schedule import MarketSchedule -from pythclient.pythaccounts import PythPriceAccount, PythPriceType, PythProductAccount +from pythclient.pythaccounts import PythProductAccount from pythclient.pythclient import PythClient from pythclient.solana import ( SOLANA_DEVNET_HTTP_ENDPOINT, @@ -90,6 +90,11 @@ async def run(self) -> None: products = await self.get_pyth_products() coingecko_prices, coingecko_updates = await self.get_coingecko_prices() crosschain_prices = await self.get_crosschain_prices() + await self.refresh_all_pyth_prices() + + logger.info( + "Refreshed all state: products, coingecko, crosschain, pyth" + ) health_server.observer_ready = True @@ -108,7 +113,7 @@ async def run(self) -> None: # for each price account) and a list of publisher states (one # for each publisher). states: List[State] = [] - price_accounts = await self.get_pyth_prices(product) + price_accounts = product.prices crosschain_price = crosschain_prices.get( b58decode(product.first_price_account_key.key).hex(), None @@ -231,21 +236,19 @@ async def get_pyth_products(self) -> List[PythProductAccount]: ).inc() raise - async def get_pyth_prices( - self, product: PythProductAccount - ) -> Dict[PythPriceType, PythPriceAccount]: - logger.debug("Fetching Pyth price accounts...") + async def refresh_all_pyth_prices(self) -> None: + """Refresh all Pyth prices once for all products.""" + logger.debug("Refreshing all Pyth price accounts...") try: async with self.pyth_throttler: with metrics.time_operation( metrics.api_request_duration, service="pyth", endpoint="prices" ): - result = await product.refresh_prices() + await self.pyth_client.refresh_all_prices() metrics.api_request_total.labels( service="pyth", endpoint="prices", status="success" ).inc() - return result except Exception: metrics.api_request_total.labels( service="pyth", endpoint="prices", status="error" From 528203e53b45406f6a12f6c452e5231ebeeb9d1e Mon Sep 17 00:00:00 2001 From: Ali Behjati Date: Thu, 27 Nov 2025 17:34:56 +0100 Subject: [PATCH 3/8] fix: handle price of 0 --- pyth_observer/check/price_feed.py | 8 ++++++++ pyth_observer/check/publisher.py | 21 ++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/pyth_observer/check/price_feed.py b/pyth_observer/check/price_feed.py index e0c2160..acbae2a 100644 --- a/pyth_observer/check/price_feed.py +++ b/pyth_observer/check/price_feed.py @@ -112,6 +112,10 @@ def run(self) -> bool: if self.__state.status != PythPriceStatus.TRADING: return True + # Skip if CoinGecko price is zero + if self.__state.coingecko_price == 0: + return True + deviation = ( abs(self.__state.price_aggregate - self.__state.coingecko_price) / self.__state.coingecko_price @@ -250,6 +254,10 @@ def run(self) -> bool: if staleness > self.__max_staleness: return True + # Skip if price aggregate is zero + if self.__state.price_aggregate == 0: + return True + deviation = ( abs(self.__state.crosschain_price["price"] - self.__state.price_aggregate) / self.__state.price_aggregate diff --git a/pyth_observer/check/publisher.py b/pyth_observer/check/publisher.py index 90efc9d..b19a5e2 100644 --- a/pyth_observer/check/publisher.py +++ b/pyth_observer/check/publisher.py @@ -94,6 +94,11 @@ def run(self) -> bool: return True diff = self.__state.price - self.__state.price_aggregate + + # Skip if confidence interval aggregate is zero + if self.__state.confidence_interval_aggregate == 0: + return True + intervals_away = abs(diff / self.__state.confidence_interval_aggregate) # Pass if price diff is less than max interval distance @@ -105,7 +110,11 @@ def run(self) -> bool: def error_message(self) -> Dict[str, Any]: diff = self.__state.price - self.__state.price_aggregate - intervals_away = abs(diff / self.__state.confidence_interval_aggregate) + if self.__state.confidence_interval_aggregate == 0: + intervals_away = abs(diff) + else: + intervals_away = abs(diff / self.__state.confidence_interval_aggregate) + return { "msg": f"{self.__state.publisher_name} price is {intervals_away} times away from confidence.", "type": "PublisherWithinAggregateConfidenceCheck", @@ -218,7 +227,7 @@ def run(self) -> bool: return True # Skip if published price is zero - if self.__state.price == 0: + if self.__state.price == 0 or self.__state.price_aggregate == 0: return True deviation = (self.ci_adjusted_price_diff() / self.__state.price_aggregate) * 100 @@ -231,7 +240,13 @@ def run(self) -> bool: return False def error_message(self) -> Dict[str, Any]: - deviation = (self.ci_adjusted_price_diff() / self.__state.price_aggregate) * 100 + if self.__state.price_aggregate == 0: + deviation = self.ci_adjusted_price_diff() + else: + deviation = ( + self.ci_adjusted_price_diff() / self.__state.price_aggregate + ) * 100 + return { "msg": f"{self.__state.publisher_name} price is too far from aggregate price.", "type": "PublisherPriceCheck", From aeb01c901e0fffdee56327b95170e415affc6da3 Mon Sep 17 00:00:00 2001 From: Ali Behjati Date: Thu, 27 Nov 2025 17:40:34 +0100 Subject: [PATCH 4/8] refactor: allow float deviation pct --- pyth_observer/check/price_feed.py | 4 ++-- pyth_observer/check/publisher.py | 6 ++++-- tests/test_checks_publisher.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pyth_observer/check/price_feed.py b/pyth_observer/check/price_feed.py index acbae2a..4e7b847 100644 --- a/pyth_observer/check/price_feed.py +++ b/pyth_observer/check/price_feed.py @@ -93,7 +93,7 @@ def error_message(self) -> Dict[str, Any]: class PriceFeedCoinGeckoCheck(PriceFeedCheck): def __init__(self, state: PriceFeedState, config: PriceFeedCheckConfig) -> None: self.__state = state - self.__max_deviation: int = int(config["max_deviation"]) # Percentage + self.__max_deviation: float = float(config["max_deviation"]) # Percentage self.__max_staleness: int = int(config["max_staleness"]) # Seconds def state(self) -> PriceFeedState: @@ -225,7 +225,7 @@ def error_message(self) -> Dict[str, Any]: class PriceFeedCrossChainDeviationCheck(PriceFeedCheck): def __init__(self, state: PriceFeedState, config: PriceFeedCheckConfig) -> None: self.__state = state - self.__max_deviation: int = int(config["max_deviation"]) + self.__max_deviation: float = float(config["max_deviation"]) self.__max_staleness: int = int(config["max_staleness"]) def state(self) -> PriceFeedState: diff --git a/pyth_observer/check/publisher.py b/pyth_observer/check/publisher.py index b19a5e2..161f5cb 100644 --- a/pyth_observer/check/publisher.py +++ b/pyth_observer/check/publisher.py @@ -70,7 +70,7 @@ def error_message(self) -> Dict[str, Any]: class PublisherWithinAggregateConfidenceCheck(PublisherCheck): def __init__(self, state: PublisherState, config: PublisherCheckConfig) -> None: self.__state = state - self.__max_interval_distance: int = int(config["max_interval_distance"]) + self.__max_interval_distance: float = float(config["max_interval_distance"]) def state(self) -> PublisherState: return self.__state @@ -206,7 +206,9 @@ def error_message(self) -> Dict[str, Any]: class PublisherPriceCheck(PublisherCheck): def __init__(self, state: PublisherState, config: PublisherCheckConfig) -> None: self.__state = state - self.__max_aggregate_distance: int = int(config["max_aggregate_distance"]) # % + self.__max_aggregate_distance: float = float( + config["max_aggregate_distance"] + ) # % self.__max_slot_distance: int = int(config["max_slot_distance"]) # Slots def state(self) -> PublisherState: diff --git a/tests/test_checks_publisher.py b/tests/test_checks_publisher.py index 80dc8d5..fa4657a 100644 --- a/tests/test_checks_publisher.py +++ b/tests/test_checks_publisher.py @@ -46,7 +46,7 @@ def make_publisher_state( def test_publisher_price_check(): def check_is_ok( - state: PublisherState, max_aggregate_distance: int, max_slot_distance: int + state: PublisherState, max_aggregate_distance: float, max_slot_distance: int ) -> bool: return PublisherPriceCheck( state, From 00745d490f219b96581a0f5a99c6784ab7bbc908 Mon Sep 17 00:00:00 2001 From: Ali Behjati Date: Thu, 27 Nov 2025 17:52:06 +0100 Subject: [PATCH 5/8] refactor: remove crosschain checks --- pyth_observer/__init__.py | 31 +------- pyth_observer/check/price_feed.py | 126 +----------------------------- pyth_observer/crosschain.py | 63 --------------- pyth_observer/metrics.py | 14 ---- sample.config.yaml | 10 +-- tests/test_checks_price_feed.py | 6 -- 6 files changed, 4 insertions(+), 246 deletions(-) delete mode 100644 pyth_observer/crosschain.py diff --git a/pyth_observer/__init__.py b/pyth_observer/__init__.py index 3146a3a..4eaa318 100644 --- a/pyth_observer/__init__.py +++ b/pyth_observer/__init__.py @@ -2,7 +2,6 @@ import os from typing import Any, Dict, List, Literal, Tuple -from base58 import b58decode from loguru import logger from pythclient.market_schedule import MarketSchedule from pythclient.pythaccounts import PythProductAccount @@ -22,8 +21,6 @@ from pyth_observer.check.price_feed import PriceFeedState from pyth_observer.check.publisher import PublisherState from pyth_observer.coingecko import Symbol, get_coingecko_prices -from pyth_observer.crosschain import CrosschainPrice -from pyth_observer.crosschain import CrosschainPriceObserver as Crosschain from pyth_observer.dispatch import Dispatch from pyth_observer.metrics import metrics from pyth_observer.models import Publisher @@ -71,8 +68,6 @@ def __init__( rate_limit=int(config["network"]["request_rate_limit"]), period=float(config["network"]["request_rate_period"]), ) - self.crosschain = Crosschain(self.config["network"]["crosschain_endpoint"]) - self.crosschain_throttler = Throttler(rate_limit=1, period=1) self.coingecko_mapping = coingecko_mapping metrics.set_observer_info( @@ -89,12 +84,9 @@ async def run(self) -> None: products = await self.get_pyth_products() coingecko_prices, coingecko_updates = await self.get_coingecko_prices() - crosschain_prices = await self.get_crosschain_prices() await self.refresh_all_pyth_prices() - logger.info( - "Refreshed all state: products, coingecko, crosschain, pyth" - ) + logger.info("Refreshed all state: products, coingecko, pyth") health_server.observer_ready = True @@ -115,10 +107,6 @@ async def run(self) -> None: states: List[State] = [] price_accounts = product.prices - crosschain_price = crosschain_prices.get( - b58decode(product.first_price_account_key.key).hex(), None - ) - for _, price_account in price_accounts.items(): # Handle potential None for min_publishers if ( @@ -155,7 +143,6 @@ async def run(self) -> None: coingecko_update=coingecko_updates.get( product.attrs["base"] ), - crosschain_price=crosschain_price, ) states.append(price_feed_state) @@ -288,19 +275,3 @@ async def get_coingecko_prices( updates[symbol] = data[symbol]["last_updated_at"] return (prices, updates) - - async def get_crosschain_prices(self) -> Dict[str, CrosschainPrice]: - try: - with metrics.time_operation( - metrics.api_request_duration, service="crosschain", endpoint="prices" - ): - result = await self.crosschain.get_crosschain_prices() - metrics.api_request_total.labels( - service="crosschain", endpoint="prices", status="success" - ).inc() - return result - except Exception: - metrics.api_request_total.labels( - service="crosschain", endpoint="prices", status="error" - ).inc() - raise diff --git a/pyth_observer/check/price_feed.py b/pyth_observer/check/price_feed.py index 4e7b847..d9b431b 100644 --- a/pyth_observer/check/price_feed.py +++ b/pyth_observer/check/price_feed.py @@ -4,13 +4,10 @@ from typing import Any, Dict, Optional, Protocol, runtime_checkable from zoneinfo import ZoneInfo -import arrow from pythclient.market_schedule import MarketSchedule from pythclient.pythaccounts import PythPriceStatus from pythclient.solana import SolanaPublicKey -from pyth_observer.crosschain import CrosschainPrice - @dataclass class PriceFeedState: @@ -25,7 +22,6 @@ class PriceFeedState: confidence_interval_aggregate: float coingecko_price: Optional[float] coingecko_update: Optional[int] - crosschain_price: Optional[CrosschainPrice] PriceFeedCheckConfig = Dict[str, str | float | int | bool] @@ -167,128 +163,8 @@ def error_message(self) -> Dict[str, Any]: } -class PriceFeedCrossChainOnlineCheck(PriceFeedCheck): - def __init__(self, state: PriceFeedState, config: PriceFeedCheckConfig) -> None: - self.__state = state - self.__max_staleness: int = int(config["max_staleness"]) - - def state(self) -> PriceFeedState: - return self.__state - - def run(self) -> bool: - # Skip if not trading - if self.__state.status != PythPriceStatus.TRADING: - return True - - market_open = self.__state.schedule.is_market_open( - datetime.now(ZoneInfo("America/New_York")), - ) - - # Skip if not trading hours (for equities) - if not market_open: - return True - - # Price should exist, it fails otherwise - if not self.__state.crosschain_price: - return False - - # Skip if publish time is zero - if not self.__state.crosschain_price["publish_time"]: - return True - - staleness = ( - self.__state.crosschain_price["snapshot_time"] - - self.__state.crosschain_price["publish_time"] - ) - - # Pass if current staleness is less than `max_staleness` - if staleness < self.__max_staleness: - return True - - # Fail - return False - - def error_message(self) -> Dict[str, Any]: - if self.__state.crosschain_price: - publish_time = arrow.get(self.__state.crosschain_price["publish_time"]) - else: - publish_time = arrow.get(0) - - return { - "msg": f"{self.__state.symbol} isn't online at the price service.", - "type": "PriceFeedCrossChainOnlineCheck", - "symbol": self.__state.symbol, - "last_publish_time": publish_time.format("YYYY-MM-DD HH:mm:ss ZZ"), - } - - -class PriceFeedCrossChainDeviationCheck(PriceFeedCheck): - def __init__(self, state: PriceFeedState, config: PriceFeedCheckConfig) -> None: - self.__state = state - self.__max_deviation: float = float(config["max_deviation"]) - self.__max_staleness: int = int(config["max_staleness"]) - - def state(self) -> PriceFeedState: - return self.__state - - def run(self) -> bool: - # Skip if does not exist - if not self.__state.crosschain_price: - return True - - # Skip if not trading - if self.__state.status != PythPriceStatus.TRADING: - return True - - market_open = self.__state.schedule.is_market_open( - datetime.now(ZoneInfo("America/New_York")), - ) - - # Skip if not trading hours (for equities) - if not market_open: - return True - - staleness = int(time.time()) - self.__state.crosschain_price["publish_time"] - - # Skip if price is stale - if staleness > self.__max_staleness: - return True - - # Skip if price aggregate is zero - if self.__state.price_aggregate == 0: - return True - - deviation = ( - abs(self.__state.crosschain_price["price"] - self.__state.price_aggregate) - / self.__state.price_aggregate - ) * 100 - - # Pass if price isn't higher than maxium deviation - if deviation < self.__max_deviation: - return True - - # Fail - return False - - def error_message(self) -> Dict[str, Any]: - # It can never happen because of the check logic but linter could not understand it. - price = ( - self.__state.crosschain_price["price"] - if self.__state.crosschain_price - else None - ) - return { - "msg": f"{self.__state.symbol} is too far at the price service.", - "type": "PriceFeedCrossChainDeviationCheck", - "symbol": self.__state.symbol, - "price": self.__state.price_aggregate, - "price_at_price_service": price, - } - - PRICE_FEED_CHECKS = [ PriceFeedCoinGeckoCheck, - PriceFeedCrossChainDeviationCheck, - PriceFeedCrossChainOnlineCheck, + PriceFeedConfidenceIntervalCheck, PriceFeedOfflineCheck, ] diff --git a/pyth_observer/crosschain.py b/pyth_observer/crosschain.py deleted file mode 100644 index 2477d1c..0000000 --- a/pyth_observer/crosschain.py +++ /dev/null @@ -1,63 +0,0 @@ -import time -from typing import Dict, TypedDict - -import requests -from aiohttp import ClientSession -from loguru import logger -from more_itertools import chunked -from throttler import throttle - - -class CrosschainPrice(TypedDict): - price: float - conf: float - publish_time: int # UNIX timestamp - snapshot_time: int # UNIX timestamp - - -class CrosschainPriceObserver: - def __init__(self, url: str) -> None: - self.url = url - self.valid = self.is_endpoint_valid() - - def is_endpoint_valid(self) -> bool: - try: - return requests.head(self.url).status_code == 200 - except requests.ConnectionError: - logger.error("failed to connect to cross-chain api") - return False - - @throttle(rate_limit=1, period=1) - async def get_crosschain_prices(self) -> Dict[str, CrosschainPrice]: - async with ClientSession( - headers={"content-type": "application/json"} - ) as session: - price_feeds_index_url = f"{self.url}/v2/price_feeds" - - async with session.get(price_feeds_index_url) as response: - price_feeds_index = await response.json() - - price_feeds = [] - - for feeds in chunked(price_feeds_index, 25): - price_feeds_url = f"{self.url}/v2/updates/price/latest" - - # aiohttp does not support encoding array params using PHP-style `ids=[]` - # naming, so we encode it manually and append to the URL. - query_string = "?" + "&".join(f"ids[]={v['id']}" for v in feeds) - async with session.get( - price_feeds_url + query_string, - ) as response: - response_json = await response.json() - price_feeds.extend(response_json["parsed"]) - - # Return a dictionary of id -> {price, conf, expo} for fast lookup - return { - data["id"]: { - "price": int(data["price"]["price"]) * 10 ** data["price"]["expo"], - "conf": int(data["price"]["conf"]) * 10 ** data["price"]["expo"], - "publish_time": data["price"]["publish_time"], - "snapshot_time": int(time.time()), - } - for data in price_feeds - } diff --git a/pyth_observer/metrics.py b/pyth_observer/metrics.py index 1d04e20..2c22adb 100644 --- a/pyth_observer/metrics.py +++ b/pyth_observer/metrics.py @@ -131,13 +131,6 @@ def __init__(self, registry: CollectorRegistry = REGISTRY): registry=registry, ) - self.crosschain_price_age = Gauge( - "pyth_observer_crosschain_price_age_seconds", - "Age of cross-chain price data in seconds", - ["symbol"], - registry=registry, - ) - self.latest_block_slot = Gauge( "pyth_observer_latest_block_slot", "Latest Solana block slot observed", @@ -205,13 +198,6 @@ def update_price_feed_metrics(self, state: PriceFeedState) -> None: age = time.time() - state.coingecko_update self.coingecko_price_age.labels(symbol=state.symbol).set(age) - if state.crosschain_price and state.crosschain_price.get("publish_time"): - age = ( - state.crosschain_price["snapshot_time"] - - state.crosschain_price["publish_time"] - ) - self.crosschain_price_age.labels(symbol=state.symbol).set(age) - self.latest_block_slot.set(state.latest_block_slot) def record_api_request( diff --git a/sample.config.yaml b/sample.config.yaml index e87e84f..ce159dc 100644 --- a/sample.config.yaml +++ b/sample.config.yaml @@ -3,7 +3,6 @@ network: http_endpoint: "https://api2.pythnet.pyth.network" ws_endpoint: "wss://api2.pythnet.pyth.network" first_mapping: "AHtgzX45WTKfkPG53L6WYhGEXwQkN1BVknET3sVsLL8J" - crosschain_endpoint: "https://hermes.pyth.network" request_rate_limit: 10 request_rate_period: 1 events: @@ -28,13 +27,8 @@ checks: enable: true max_deviation: 5 max_staleness: 60 - PriceFeedCrossChainOnlineCheck: - enable: true - max_staleness: 60 - PriceFeedCrossChainDeviationCheck: - enable: true - max_deviation: 5 - max_staleness: 60 + PriceFeedConfidenceIntervalCheck: + enable: false # Publisher checks PublisherWithinAggregateConfidenceCheck: enable: false diff --git a/tests/test_checks_price_feed.py b/tests/test_checks_price_feed.py index b774c7a..3fdfb62 100644 --- a/tests/test_checks_price_feed.py +++ b/tests/test_checks_price_feed.py @@ -18,12 +18,6 @@ def test_price_feed_offline_check(): confidence_interval_aggregate=10.0, coingecko_price=1005.0, coingecko_update=0, - crosschain_price={ - "price": 1003.0, - "conf": 10.0, - "publish_time": 123, - "snapshot_time": 123, - }, ) assert PriceFeedOfflineCheck( From 4510ec79cdb32e5f87b91e12886affc6104c8f36 Mon Sep 17 00:00:00 2001 From: Ali Behjati Date: Fri, 28 Nov 2025 13:34:46 +0100 Subject: [PATCH 6/8] feat: add script to build coingecko mapping --- README.md | 57 ++ scripts/build_coingecko_mapping.py | 852 +++++++++++++++++++++++++++++ 2 files changed, 909 insertions(+) create mode 100755 scripts/build_coingecko_mapping.py diff --git a/README.md b/README.md index 671e8cd..2811c61 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,63 @@ Use `poetry run pyth-observer --help` for documentation on arguments and environ To run tests, use `poetry run pytest`. +## Building CoinGecko Mapping + +The `scripts/build_coingecko_mapping.py` script automatically generates a CoinGecko mapping file by fetching all price feeds from the Pyth Hermes API and matching them with CoinGecko's coin list using fuzzy matching. + +### Basic Usage + +```sh +# Generate a new mapping file +poetry run python scripts/build_coingecko_mapping.py + +# Compare with existing mapping file +poetry run python scripts/build_coingecko_mapping.py -e sample.coingecko.yaml + +# Specify custom output file +poetry run python scripts/build_coingecko_mapping.py -o my_mapping.json + +# Skip price validation (faster, but less thorough) +poetry run python scripts/build_coingecko_mapping.py --no-validate-prices + +# Adjust maximum price deviation threshold (default: 10.0%) +poetry run python scripts/build_coingecko_mapping.py --max-price-deviation 5.0 +``` + +### How It Works + +1. **Fetches Pyth Price Feeds**: Retrieves all price feeds from `https://hermes.pyth.network/v2/price_feeds` +2. **Extracts Crypto Symbols**: Filters for Crypto asset types and extracts symbols (e.g., "Crypto.BTC/USD") +3. **Matches with CoinGecko**: Uses multiple matching strategies: + - Exact symbol match (case-insensitive) + - Fuzzy symbol matching + - Fuzzy name matching based on Pyth description +4. **Validates Mappings**: Compares generated mappings against known correct mappings +5. **Validates Prices** (optional): Compares prices from Hermes and CoinGecko to detect mismatches +6. **Generates Warnings**: Flags symbols that need manual review: + - Low-confidence fuzzy matches (shows similarity score) + - Symbols with no matches found + - Price deviations between sources + +### Output + +The script generates a JSON file in the format: +```json +{ + "Crypto.BTC/USD": "bitcoin", + "Crypto.ETH/USD": "ethereum", + ... +} +``` + +The script provides a summary showing: +- Total symbols mapped +- Exact matches (100% confidence) +- Fuzzy matches (needs review) +- No matches found + +Review the warnings output to manually verify and adjust any low-confidence matches before using the generated mapping file. + ## Configuration See `sample.config.yaml` for configuration options. diff --git a/scripts/build_coingecko_mapping.py b/scripts/build_coingecko_mapping.py new file mode 100755 index 0000000..0cfa5fe --- /dev/null +++ b/scripts/build_coingecko_mapping.py @@ -0,0 +1,852 @@ +#!/usr/bin/env python3 +""" +Script to build CoinGecko mapping file from Pyth Hermes API and CoinGecko API. + +This script: +1. Fetches all price feeds from Pyth Hermes API +2. Extracts base symbols (especially for Crypto assets) +3. Gets CoinGecko coin list +4. Matches using Pyth description (most reliable) and symbol matching +5. Generates the mapping file with warnings for non-100% matches +""" + +import json +import sys +import time +from difflib import SequenceMatcher +from typing import Any, Dict, List, Optional, Tuple + +import requests +from loguru import logger +from pycoingecko import CoinGeckoAPI + +# Configure logger +logger.remove() +logger.add( + sys.stderr, + level="INFO", + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}", +) + +HERMES_API_URL = "https://hermes.pyth.network/v2/price_feeds" +COINGECKO_API = CoinGeckoAPI() + +# Known mappings for validation only (not used in matching logic) +# Format: Pyth symbol -> CoinGecko ID +KNOWN_MAPPINGS = { + "Crypto.BTC/USD": "bitcoin", + "Crypto.ETH/USD": "ethereum", + "Crypto.USDT/USD": "tether", + "Crypto.USDC/USD": "usd-coin", + "Crypto.BNB/USD": "binancecoin", + "Crypto.SOL/USD": "solana", + "Crypto.XRP/USD": "ripple", + "Crypto.DOGE/USD": "dogecoin", + "Crypto.ADA/USD": "cardano", + "Crypto.AVAX/USD": "avalanche-2", + "Crypto.DOT/USD": "polkadot", + "Crypto.MATIC/USD": "matic-network", + "Crypto.LINK/USD": "chainlink", + "Crypto.UNI/USD": "uniswap", + "Crypto.ATOM/USD": "cosmos", + "Crypto.LTC/USD": "litecoin", + "Crypto.BCH/USD": "bitcoin-cash", + "Crypto.XLM/USD": "stellar", + "Crypto.ALGO/USD": "algorand", + "Crypto.VET/USD": "vechain", + "Crypto.ICP/USD": "internet-computer", + "Crypto.FIL/USD": "filecoin", + "Crypto.TRX/USD": "tron", + "Crypto.ETC/USD": "ethereum-classic", + "Crypto.EOS/USD": "eos", + "Crypto.AAVE/USD": "aave", + "Crypto.MKR/USD": "maker", + "Crypto.COMP/USD": "compound-governance-token", + "Crypto.YFI/USD": "yearn-finance", + "Crypto.SNX/USD": "havven", + "Crypto.SUSHI/USD": "sushi", + "Crypto.CRV/USD": "curve-dao-token", + "Crypto.1INCH/USD": "1inch", + "Crypto.ENJ/USD": "enjincoin", + "Crypto.BAT/USD": "basic-attention-token", + "Crypto.ZRX/USD": "0x", + "Crypto.MANA/USD": "decentraland", + "Crypto.SAND/USD": "the-sandbox", + "Crypto.GALA/USD": "gala", + "Crypto.AXS/USD": "axie-infinity", + "Crypto.CHZ/USD": "chiliz", + "Crypto.FLOW/USD": "flow", + "Crypto.NEAR/USD": "near", + "Crypto.FTM/USD": "fantom", + "Crypto.HBAR/USD": "hedera-hashgraph", + "Crypto.EGLD/USD": "elrond-erd-2", + "Crypto.THETA/USD": "theta-token", + "Crypto.ZIL/USD": "zilliqa", + "Crypto.IOTA/USD": "iota", + "Crypto.ONE/USD": "harmony", + "Crypto.WAVES/USD": "waves", + "Crypto.XTZ/USD": "tezos", + "Crypto.DASH/USD": "dash", + "Crypto.ZEC/USD": "zcash", + "Crypto.XMR/USD": "monero", + "Crypto.ANC/USD": "anchor-protocol", + "Crypto.APE/USD": "apecoin", + "Crypto.ATLAS/USD": "star-atlas", + "Crypto.AUST/USD": "anchorust", + "Crypto.BETH/USD": "binance-eth", + "Crypto.BRZ/USD": "brz", + "Crypto.BUSD/USD": "binance-usd", + "Crypto.C98/USD": "coin98", + "Crypto.COPE/USD": "cope", + "Crypto.CUSD/USD": "celo-dollar", + "Crypto.FIDA/USD": "bonfida", + "Crypto.FTT/USD": "ftx-token", + "Crypto.GMT/USD": "stepn", + "Crypto.GOFX/USD": "goosefx", + "Crypto.HXRO/USD": "hxro", + "Crypto.INJ/USD": "injective-protocol", + "Crypto.JET/USD": "jet", + "Crypto.LUNA/USD": "terra-luna-2", + "Crypto.LUNC/USD": "terra-luna", + "Crypto.MER/USD": "mercurial", + "Crypto.MIR/USD": "mirror-protocol", + "Crypto.MNGO/USD": "mango-markets", + "Crypto.MSOL/USD": "msol", + "Crypto.ORCA/USD": "orca", + "Crypto.PAI/USD": "parrot-usd", + "Crypto.PORT/USD": "port-finance", + "Crypto.RAY/USD": "raydium", + "Crypto.SBR/USD": "saber", + "Crypto.SCNSOL/USD": "socean-staked-sol", + "Crypto.SLND/USD": "solend", + "Crypto.SNY/USD": "synthetify-token", + "Crypto.SRM/USD": "serum", + "Crypto.STEP/USD": "step-finance", + "Crypto.STSOL/USD": "lido-staked-sol", + "Crypto.TUSD/USD": "true-usd", + "Crypto.USTC/USD": "terrausd", + "Crypto.VAI/USD": "vai", + "Crypto.XVS/USD": "venus", + "Crypto.ZBC/USD": "zebec-protocol", +} + + +def normalize_symbol(symbol: str) -> str: + """Normalize symbol - only remove suffixes separated by / or -.""" + original = symbol.upper().strip() + # Only remove suffixes if they're separated by / or - + for suffix in ["-USD", "/USD", "-USDT", "/USDT", "-USDC", "/USDC"]: + if original.endswith(suffix): + return original[: -len(suffix)].strip() + return original + + +def is_non_canonical(coin_id: str, coin_name: str) -> bool: + """Check if coin is bridged/peg/wrapped (non-canonical).""" + text = (coin_id + " " + coin_name).lower() + return any( + term in text + for term in ["bridged", "peg", "wrapped", "wormhole", "binance-peg", "mapped-"] + ) + + +def normalize_text(text: str) -> str: + """Normalize text for matching (remove separators, lowercase).""" + return ( + text.lower().replace("-", "").replace("_", "").replace(" ", "").replace("/", "") + ) + + +def match_by_description( + description: str, coins: List[Dict[str, Any]] +) -> Optional[Dict[str, Any]]: + """ + Match coin using Pyth description (most reliable method). + Description format: "COIN_NAME / US DOLLAR" or similar. + """ + if not description: + return None + + # Extract words from description (e.g., "UNISWAP / US DOLLAR" -> ["uniswap"]) + # Get the first significant word (usually the coin name) + desc_parts = description.upper().split("/")[0].strip() # Get part before "/" + desc_words = [ + w.replace("-", "").replace("_", "").lower() + for w in desc_parts.replace("-", " ").split() + if len(w) > 2 and w.lower() not in ["usd", "us", "dollar", "euro", "eur", "and"] + ] + + # Also create combined version for multi-word matches (e.g., "USD COIN" -> "usdcoin") + desc_combined = "".join(desc_words) + + # First pass: find exact matches (prefer canonical) + canonical_matches = [] + non_canonical_matches = [] + + for coin in coins: + coin_id_norm = normalize_text(coin["id"]) + coin_name_norm = normalize_text(coin["name"]) + is_non_can = is_non_canonical(coin["id"], coin["name"]) + + # Check exact word match with coin ID (most reliable) + for word in desc_words: + if word == coin_id_norm: + if is_non_can: + non_canonical_matches.append(coin) + else: + # Return immediately for canonical exact match + return coin + + # Check combined description match + if desc_combined == coin_id_norm: + if is_non_can: + non_canonical_matches.append(coin) + else: + canonical_matches.append(coin) + + # Check if coin name matches + if coin_name_norm in desc_combined or any( + word == coin_name_norm for word in desc_words + ): + if is_non_can: + non_canonical_matches.append(coin) + else: + canonical_matches.append(coin) + + # Return first canonical match, or first non-canonical if no canonical found + if canonical_matches: + return canonical_matches[0] + if non_canonical_matches: + return non_canonical_matches[0] + + return None + + +def score_coin(symbol: str, coin: Dict[str, Any], description: str = "") -> float: + """Score a coin match. Higher is better.""" + coin_id = coin["id"].lower() + coin_name = coin["name"].lower() + symbol_lower = symbol.lower() + + # Heavy penalty for non-canonical coins + if is_non_canonical(coin_id, coin_name): + base = 0.1 + else: + base = 1.0 + + # Penalty for generic names (name == symbol) + if coin_name == symbol_lower: + base *= 0.3 + + # Bonus if description matches + if description: + desc_norm = normalize_text(description) + coin_id_norm = normalize_text(coin_id) + if coin_id_norm in desc_norm: + return base + 0.5 + coin_name_norm = normalize_text(coin_name) + if coin_name_norm in desc_norm: + return base + 0.3 + + # Similarity bonus + similarity = SequenceMatcher(None, symbol_lower, coin_name).ratio() + return base * (0.5 + similarity * 0.5) + + +def find_coingecko_match( + pyth_base: str, + coin_lookup: Dict[str, Any], + description: str = "", +) -> Tuple[Optional[str], float, str]: + """Find the best CoinGecko match for a Pyth base symbol. + + Returns: + Tuple of (coin_id, confidence_score, match_type) + """ + normalized = normalize_symbol(pyth_base) + + # Strategy 1: Exact symbol match + if normalized in coin_lookup["by_symbol"]: + coins = coin_lookup["by_symbol"][normalized] + + if len(coins) == 1: + return coins[0]["id"], 1.0, "exact_symbol" + + # Try description matching first (most reliable) + desc_match = match_by_description(description, coins) + if desc_match: + return desc_match["id"], 1.0, "exact_symbol" + + # Score all coins and pick best + best_coin = None + best_score = -1.0 + + for coin in coins: + score = score_coin(normalized, coin, description) + if score > best_score: + best_score = score + best_coin = coin + + if best_coin: + return best_coin["id"], 1.0, "exact_symbol" + + # Strategy 2: Fuzzy match on symbol and coin ID + best_coin = None + best_score = 0.0 + + for coin in coin_lookup["all_coins"]: + coin_symbol = coin["symbol"].upper() + coin_id = coin["id"].upper() + + # Check exact match with coin ID first (most reliable) + if normalized == coin_id: + return coin["id"], 1.0, "fuzzy_symbol" + + # Check similarity with both symbol and ID, prefer ID matches + symbol_score = SequenceMatcher(None, normalized, coin_symbol).ratio() + id_score = SequenceMatcher(None, normalized, coin_id).ratio() + + # Use the better of the two scores, with slight preference for ID matches + score = max(symbol_score, id_score * 1.01) # 1% bonus for ID matches + + if score > best_score and score >= 0.7: + best_score = score + best_coin = coin + + if best_coin: + # If we found an exact ID match, return 100% confidence + if normalized == best_coin["id"].upper(): + return best_coin["id"], 1.0, "fuzzy_symbol" + return best_coin["id"], best_score, "fuzzy_symbol" + + return None, 0.0, "no_match" + + +def validate_known_mappings( + mapping: Dict[str, str], coin_lookup: Dict[str, Any] +) -> Tuple[bool, List[str]]: + """Validate that known mappings match the generated mapping.""" + errors = [] + + for symbol, expected_id in KNOWN_MAPPINGS.items(): + if symbol in mapping: + actual_id = mapping[symbol] + if actual_id != expected_id: + expected_coin = coin_lookup["by_id"].get(expected_id, {}) + actual_coin = coin_lookup["by_id"].get(actual_id, {}) + expected_name = expected_coin.get("name", expected_id) + actual_name = actual_coin.get("name", actual_id) + + errors.append( + f"❌ VALIDATION FAILED: {symbol} mapped to '{actual_id}' ({actual_name}) " + f"but expected '{expected_id}' ({expected_name})" + ) + + return len(errors) == 0, errors + + +def get_pyth_price_feeds() -> List[Dict[str, Any]]: + """Fetch all price feeds from Pyth Hermes API.""" + logger.info(f"Fetching price feeds from {HERMES_API_URL}...") + try: + response = requests.get(HERMES_API_URL, timeout=30) + response.raise_for_status() + feeds = response.json() + logger.info(f"Fetched {len(feeds)} price feeds from Hermes API") + time.sleep(1) # Rate limit protection + return feeds + except Exception as e: + logger.error(f"Failed to fetch price feeds from Hermes API: {e}") + sys.exit(1) + + +def get_coingecko_coin_list() -> Dict[str, Any]: + """Fetch CoinGecko coin list and create lookup dictionaries.""" + logger.info("Fetching CoinGecko coin list...") + try: + coins = COINGECKO_API.get_coins_list() + logger.info(f"Fetched {len(coins)} coins from CoinGecko") + time.sleep(1) # Rate limit protection + + by_id = {coin["id"]: coin for coin in coins} + by_symbol = {} + for coin in coins: + symbol_upper = coin["symbol"].upper() + if symbol_upper not in by_symbol: + by_symbol[symbol_upper] = [] + by_symbol[symbol_upper].append(coin) + + return {"by_id": by_id, "by_symbol": by_symbol, "all_coins": coins} + except Exception as e: + logger.error(f"Failed to fetch CoinGecko coin list: {e}") + sys.exit(1) + + +def get_hermes_prices(symbol_to_feed_id: Dict[str, str]) -> Dict[str, float]: + """Get latest prices from Hermes API for mapped symbols.""" + logger.info("Fetching prices from Hermes API...") + hermes_prices = {} + + try: + # Get price updates for all feeds in batches + feed_ids = list(symbol_to_feed_id.values()) + batch_size = 50 + + for i in range(0, len(feed_ids), batch_size): + batch = feed_ids[i : i + batch_size] + query_string = "?" + "&".join(f"ids[]={feed_id}" for feed_id in batch) + url = f"{HERMES_API_URL.replace('/price_feeds', '/updates/price/latest')}{query_string}" + + response = requests.get(url, timeout=30) + response.raise_for_status() + data = response.json() + + for feed_data in data.get("parsed", []): + feed_id = feed_data.get("id") + price_info = feed_data.get("price", {}) + if price_info: + price_str = price_info.get("price", "0") + expo = price_info.get("expo", 0) + try: + price = int(price_str) + # Convert to actual price: price * 10^expo + actual_price = price * (10**expo) + if actual_price > 0: + hermes_prices[feed_id] = actual_price + except (ValueError, TypeError): + continue + + time.sleep(1) # Rate limit protection - wait 1s after each batch + + logger.info(f"Fetched {len(hermes_prices)} prices from Hermes API") + except Exception as e: + logger.warning(f"Failed to fetch Hermes prices: {e}") + + return hermes_prices + + +def get_coingecko_prices(mapping: Dict[str, str]) -> Dict[str, float]: + """Get prices from CoinGecko for mapped coins.""" + logger.info("Fetching prices from CoinGecko...") + coingecko_prices = {} + + try: + # Get unique coin IDs + coin_ids = list(set(mapping.values())) + + # CoinGecko API can handle up to ~1000 IDs at once, but let's batch to be safe + batch_size = 200 + for i in range(0, len(coin_ids), batch_size): + batch = coin_ids[i : i + batch_size] + prices = COINGECKO_API.get_price( + ids=batch, vs_currencies="usd", include_last_updated_at=False + ) + + for coin_id, price_data in prices.items(): + if "usd" in price_data: + coingecko_prices[coin_id] = price_data["usd"] + + time.sleep(2) # Rate limit protection - wait 1s after each batch + + logger.info(f"Fetched {len(coingecko_prices)} prices from CoinGecko") + except Exception as e: + logger.warning(f"Failed to fetch CoinGecko prices: {e}") + + return coingecko_prices + + +def validate_prices( + mapping: Dict[str, str], + pyth_feeds: List[Dict[str, Any]], + max_deviation_percent: float = 10.0, +) -> Tuple[List[str], List[str], Dict[str, Dict[str, float]]]: + """ + Validate prices by comparing Hermes and CoinGecko prices. + Returns tuple of (warnings, price_mismatch_symbols, price_details) for significant price differences. + price_details is a dict mapping symbol to {'hermes_price': float, 'coingecko_price': float, 'deviation': float} + """ + warnings = [] + price_mismatch_symbols = [] + price_details = {} + + # Map symbols to feed IDs + symbol_to_feed_id = {} + for feed in pyth_feeds: + attrs = feed.get("attributes", {}) + if attrs.get("asset_type") == "Crypto": + symbol = attrs.get("symbol", "") + if symbol and symbol in mapping: + symbol_to_feed_id[symbol] = feed.get("id") + + if not symbol_to_feed_id: + return warnings, price_mismatch_symbols, price_details + + # Get prices from both sources + hermes_prices = get_hermes_prices(symbol_to_feed_id) + coingecko_prices = get_coingecko_prices(mapping) + + # Compare prices + compared = 0 + mismatches = 0 + for symbol, coin_id in mapping.items(): + feed_id = symbol_to_feed_id.get(symbol) + + if feed_id is None: + continue + + hermes_price = hermes_prices.get(feed_id) + cg_price = coingecko_prices.get(coin_id) + + # Skip if either price is missing + if not hermes_price or not cg_price: + continue + + # Skip if CoinGecko price is 0 (coin might not be actively traded) + if cg_price <= 0: + continue + + compared += 1 + deviation = abs(hermes_price - cg_price) / cg_price * 100 + + # Only warn if deviation is significant and price is meaningful + if deviation > max_deviation_percent and cg_price >= 0.01: + mismatches += 1 + warnings.append( + f"⚠️ {symbol} ({coin_id}): Price mismatch - Hermes: ${hermes_price:,.5f}, " + f"CoinGecko: ${cg_price:,.5f} (deviation: {deviation:.2f}%)" + ) + price_mismatch_symbols.append(symbol) + price_details[symbol] = { + "hermes_price": hermes_price, + "coingecko_price": cg_price, + "deviation": deviation, + } + logger.warning( + f" Price mismatch: {symbol} ({coin_id}) - " + f"Hermes: ${hermes_price:,.5f} | CoinGecko: ${cg_price:,.5f} | " + f"Deviation: {deviation:.2f}%" + ) + + if compared > 0: + logger.info(f"Compared prices for {compared} symbols") + if mismatches > 0: + logger.warning( + f"Found {mismatches} price mismatches (deviation > {max_deviation_percent}%)" + ) + + return warnings, price_mismatch_symbols, price_details + + +def build_mapping( + validate_prices_flag: bool = False, max_deviation: float = 10.0 +) -> Tuple[ + Dict[str, str], Dict[str, float], List[str], List[str], Dict[str, Dict[str, float]] +]: + """Build the CoinGecko mapping from Pyth feeds. + + Returns: + Tuple of (mapping, confidence_scores, warnings, price_mismatch_symbols, price_details) + price_details maps symbol to {'hermes_price': float, 'coingecko_price': float, 'deviation': float} + """ + pyth_feeds = get_pyth_price_feeds() + coin_lookup = get_coingecko_coin_list() + + # Extract Crypto symbols with base and descriptions + crypto_data = {} + for feed in pyth_feeds: + attrs = feed.get("attributes", {}) + if attrs.get("asset_type") == "Crypto": + symbol = attrs.get("symbol", "") + base = attrs.get("base", "") + quote_currency = attrs.get("quote_currency", "") + + if quote_currency != "USD": + continue + + if symbol and base: + if symbol not in crypto_data: + crypto_data[symbol] = { + "base": base, + "description": attrs.get("description", ""), + } + + logger.info(f"Found {len(crypto_data)} unique Crypto symbols quoted in USD") + + # Build mapping + mapping = {} + confidence_scores = {} # Track confidence scores for each symbol + warnings = [] + + for symbol in sorted(crypto_data.keys()): + base = crypto_data[symbol]["base"] + description = crypto_data[symbol]["description"] + api_id, score, match_type = find_coingecko_match(base, coin_lookup, description) + + if api_id: + mapping[symbol] = api_id + confidence_scores[symbol] = score + if score < 1.0: + warnings.append( + f"⚠️ {symbol}: Match confidence {score:.2%} ({match_type}) - " + f"matched to '{api_id}'" + ) + else: + warnings.append(f"❌ {symbol}: No match found in CoinGecko") + + # Validate against known mappings + is_valid, validation_errors = validate_known_mappings(mapping, coin_lookup) + if not is_valid: + logger.error("\n" + "=" * 60) + logger.error( + "VALIDATION FAILED: Known mappings do not match generated mappings!" + ) + logger.error("=" * 60) + for error in validation_errors: + logger.error(error) + logger.error("\nThis indicates the matching algorithm needs improvement.") + logger.error( + "Please fix the matching logic before using the generated mapping." + ) + return mapping, confidence_scores, warnings + validation_errors, [] + + logger.info("✓ Validation passed: All known mappings match generated mappings") + + # Validate prices if requested + price_mismatch_symbols = [] + price_details = {} + if validate_prices_flag: + price_warnings, price_mismatch_symbols, price_details = validate_prices( + mapping, pyth_feeds, max_deviation + ) + warnings.extend(price_warnings) + + return mapping, confidence_scores, warnings, price_mismatch_symbols, price_details + + +def load_existing_mapping(file_path: str) -> Dict[str, str]: + """Load existing mapping file if it exists.""" + try: + with open(file_path, "r") as f: + content = f.read().strip() + if content.startswith("{"): + data = json.loads(content) + # Handle both old format (dict) and new format (string) + if data and isinstance(list(data.values())[0], dict): + # Convert old format to new format + return { + k: v.get("api", v.get("market", "")) for k, v in data.items() + } + return data + except FileNotFoundError: + pass + except (json.JSONDecodeError, (KeyError, IndexError)): + logger.warning(f"Could not parse existing mapping file {file_path}") + return {} + + +def compare_mappings( + new_mapping: Dict[str, str], existing_mapping: Dict[str, str] +) -> List[str]: + """Compare new mapping with existing and return differences.""" + differences = [] + all_symbols = set(list(new_mapping.keys()) + list(existing_mapping.keys())) + + for symbol in all_symbols: + new_entry = new_mapping.get(symbol) + existing_entry = existing_mapping.get(symbol) + + if new_entry and existing_entry: + if new_entry != existing_entry: + differences.append( + f" {symbol}: Changed from '{existing_entry}' to '{new_entry}'" + ) + elif new_entry and not existing_entry: + differences.append(f" {symbol}: New entry -> '{new_entry}'") + elif existing_entry and not new_entry: + differences.append(f" {symbol}: Removed (was '{existing_entry}')") + + return differences + + +def main() -> int: + """Main function.""" + import argparse + + parser = argparse.ArgumentParser( + description="Build CoinGecko mapping file from Pyth Hermes API" + ) + parser.add_argument( + "-o", + "--output", + default="coingecko_mapping.json", + help="Output file path (default: coingecko_mapping.json)", + ) + parser.add_argument( + "-e", "--existing", help="Path to existing mapping file to compare against" + ) + parser.add_argument( + "--no-validate-prices", + action="store_true", + help="Skip price validation (by default, prices are validated)", + ) + parser.add_argument( + "--max-price-deviation", + type=float, + default=1.0, + help="Maximum price deviation percentage to warn about (default: 1.0%%)", + ) + args = parser.parse_args() + + logger.info("Starting CoinGecko mapping generation...") + + existing_mapping = {} + if args.existing: + existing_mapping = load_existing_mapping(args.existing) + if existing_mapping: + logger.info( + f"Loaded {len(existing_mapping)} existing mappings from {args.existing}" + ) + + ( + mapping, + confidence_scores, + warnings, + price_mismatch_symbols, + price_details, + ) = build_mapping( + validate_prices_flag=not args.no_validate_prices, + max_deviation=args.max_price_deviation, + ) + + # Check if validation failed + validation_failed = any("VALIDATION FAILED" in w for w in warnings) + if validation_failed: + logger.error("\nExiting with error code due to validation failures.") + return 1 + + # Filter out symbols with low confidence (< 1.0) or price mismatches + excluded_symbols = set() + excluded_low_confidence = [] + excluded_price_mismatch = [] + + # Find symbols with confidence < 1.0 + for symbol, score in confidence_scores.items(): + if score < 1.0: + excluded_symbols.add(symbol) + excluded_low_confidence.append(symbol) + + # Find symbols with price mismatches + for symbol in price_mismatch_symbols: + excluded_symbols.add(symbol) + excluded_price_mismatch.append(symbol) + + # Create filtered mapping (only high confidence, no price mismatches) + filtered_mapping = { + symbol: coin_id + for symbol, coin_id in mapping.items() + if symbol not in excluded_symbols + } + + # Log excluded entries for manual review + if excluded_low_confidence or excluded_price_mismatch: + logger.warning("\n" + "=" * 60) + logger.warning("EXCLUDED ENTRIES (for manual review):") + logger.warning("=" * 60) + + if excluded_low_confidence: + logger.warning( + f"\n⚠️ Low confidence matches (< 100%) - {len(excluded_low_confidence)} entries:" + ) + for symbol in sorted(excluded_low_confidence): + coin_id = mapping.get(symbol, "N/A") + score = confidence_scores.get(symbol, 0.0) + logger.warning(f" {symbol}: {coin_id} (confidence: {score:.2%})") + + if excluded_price_mismatch: + logger.warning( + f"\n⚠️ Price mismatches - {len(excluded_price_mismatch)} entries:" + ) + for symbol in sorted(excluded_price_mismatch): + coin_id = mapping.get(symbol, "N/A") + if symbol in price_details: + details = price_details[symbol] + hermes_price = details["hermes_price"] + cg_price = details["coingecko_price"] + deviation = details["deviation"] + logger.warning( + f" {symbol} ({coin_id}): " + f"Hermes: ${hermes_price:,.5f} | " + f"CoinGecko: ${cg_price:,.5f} | " + f"Deviation: {deviation:.2f}%" + ) + else: + logger.warning(f" {symbol}: {coin_id}") + + # Output excluded entries as JSON for easy manual addition + excluded_mapping = { + symbol: mapping[symbol] for symbol in excluded_symbols if symbol in mapping + } + if excluded_mapping: + excluded_file = args.output.replace(".json", "_excluded.json") + with open(excluded_file, "w") as f: + json.dump(excluded_mapping, f, indent=2, sort_keys=True) + logger.warning( + f"\n📝 Excluded entries saved to {excluded_file} for manual review" + ) + logger.warning("=" * 60 + "\n") + + # Output results + logger.info(f"\n{'=' * 60}") + logger.info(f"Generated mapping for {len(mapping)} symbols") + if excluded_symbols: + logger.info( + f"Excluded {len(excluded_symbols)} entries (low confidence or price mismatch)" + ) + logger.info(f"Final mapping contains {len(filtered_mapping)} symbols") + logger.info(f"{'=' * 60}\n") + + # Compare with existing if provided + if existing_mapping: + differences = compare_mappings(filtered_mapping, existing_mapping) + if differences: + logger.info(f"Found {len(differences)} differences from existing mapping:") + for diff in differences: + logger.info(diff) + logger.info("") + + # Print warnings (excluding excluded entries from warnings count) + other_warnings = [ + w + for w in warnings + if not any(symbol in w for symbol in excluded_symbols) + and "VALIDATION FAILED" not in w + ] + if other_warnings: + logger.warning(f"Found {len(other_warnings)} other warnings:") + for warning in other_warnings: + logger.warning(warning) + logger.info("") + + # Output JSON (only high confidence, no price mismatches) + with open(args.output, "w") as f: + json.dump(filtered_mapping, f, indent=2, sort_keys=True) + + logger.info(f"✓ Mapping saved to {args.output}") + + # Summary + fuzzy_matches = len(excluded_low_confidence) + no_matches = len([w for w in warnings if "No match found" in w]) + exact_matches = len(filtered_mapping) + + logger.info(f"\nSummary:") + logger.info(f" Total symbols processed: {len(mapping)}") + logger.info(f" Included in final mapping: {exact_matches} (exact matches only)") + logger.info(f" Excluded - low confidence: {fuzzy_matches}") + logger.info(f" Excluded - price mismatch: {len(excluded_price_mismatch)}") + logger.info(f" No matches found: {no_matches}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From ff41b7ae05864b658d390803acbcae0e78b80d71 Mon Sep 17 00:00:00 2001 From: Ali Behjati Date: Fri, 28 Nov 2025 13:37:25 +0100 Subject: [PATCH 7/8] refactor!: improve coingecko mapping format --- pyth_observer/__init__.py | 12 +- pyth_observer/coingecko.py | 19 +- sample.coingecko.yaml | 624 +++++++++++++++++++++++++++++++++---- 3 files changed, 578 insertions(+), 77 deletions(-) diff --git a/pyth_observer/__init__.py b/pyth_observer/__init__.py index 4eaa318..1077fd3 100644 --- a/pyth_observer/__init__.py +++ b/pyth_observer/__init__.py @@ -20,7 +20,7 @@ from pyth_observer.check import State from pyth_observer.check.price_feed import PriceFeedState from pyth_observer.check.publisher import PublisherState -from pyth_observer.coingecko import Symbol, get_coingecko_prices +from pyth_observer.coingecko import get_coingecko_prices from pyth_observer.dispatch import Dispatch from pyth_observer.metrics import metrics from pyth_observer.models import Publisher @@ -54,7 +54,7 @@ def __init__( self, config: Dict[str, Any], publishers: Dict[str, Publisher], - coingecko_mapping: Dict[str, Symbol], + coingecko_mapping: Dict[str, str], ) -> None: self.config = config self.dispatch = Dispatch(config, publishers) @@ -95,7 +95,7 @@ async def run(self) -> None: for product in products: # Skip tombstone accounts with blank metadata - if "base" not in product.attrs: + if "symbol" not in product.attrs: continue if not product.first_price_account_key: @@ -139,9 +139,11 @@ async def run(self) -> None: latest_trading_slot=price_account.last_slot, price_aggregate=price_account.aggregate_price_info.price, confidence_interval_aggregate=price_account.aggregate_price_info.confidence_interval, - coingecko_price=coingecko_prices.get(product.attrs["base"]), + coingecko_price=coingecko_prices.get( + product.attrs["symbol"] + ), coingecko_update=coingecko_updates.get( - product.attrs["base"] + product.attrs["symbol"] ), ) diff --git a/pyth_observer/coingecko.py b/pyth_observer/coingecko.py index 9841c35..b73f2d8 100644 --- a/pyth_observer/coingecko.py +++ b/pyth_observer/coingecko.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, TypedDict +from typing import Any, Dict from loguru import logger from pycoingecko import CoinGeckoAPI @@ -6,20 +6,15 @@ from throttler import throttle -class Symbol(TypedDict): - api: str - market: str - - # CoinGecko free API limit: 10-50 (varies) https://www.coingecko.com/en/api/pricing # However prices are updated every 1-10 minutes: https://www.coingecko.com/en/faq # Hence we only have to query once every minute. -@throttle(rate_limit=1, period=60) +@throttle(rate_limit=1, period=10) async def get_coingecko_prices( - mapping: Dict[str, Symbol], + symbol_to_ticker: Dict[str, str], ) -> Dict[str, Dict[str, Any]]: - inverted_mapping = {mapping[x]["api"]: x for x in mapping} - ids = [mapping[x]["api"] for x in mapping] + ticker_to_symbol = {v: k for k, v in symbol_to_ticker.items()} + ids = list(ticker_to_symbol.keys()) try: prices = CoinGeckoAPI().get_price( @@ -32,6 +27,4 @@ async def get_coingecko_prices( ) prices = {} - # remap to symbol -> prices - prices_mapping = {inverted_mapping[x]: prices[x] for x in prices} - return prices_mapping + return {ticker_to_symbol[x]: prices[x] for x in prices} diff --git a/sample.coingecko.yaml b/sample.coingecko.yaml index 215ddd8..f3202e0 100644 --- a/sample.coingecko.yaml +++ b/sample.coingecko.yaml @@ -1,60 +1,566 @@ { - "AAVE": { "api": "aave", "market": "aave" }, - "ADA": { "api": "cardano", "market": "cardano" }, - "ALGO": { "api": "algorand", "market": "algorand" }, - "ANC": { "api": "anchor-protocol", "market": "anchor-protocol" }, - "APE": { "api": "apecoin", "market": "apecoin" }, - "ATLAS": { "api": "star-atlas", "market": "star-atlas" }, - "ATOM": { "api": "cosmos", "market": "cosmos" }, - "AUST": { "api": "anchorust", "market": "anchorust" }, - "AVAX": { "api": "avalanche-2", "market": "avalanche" }, - "BCH": { "api": "bitcoin-cash", "market": "bitcoin-cash" }, - "BETH": { "api": "binance-eth", "market": "binance-eth-staking" }, - "BNB": { "api": "binancecoin", "market": "bnb" }, - "BRZ": { "api": "brz", "market": "brazilian-digital-token" }, - "BTC": { "api": "bitcoin", "market": "bitcoin" }, - "BUSD": { "api": "binance-usd", "market": "binance-usd" }, - "C98": { "api": "coin98", "market": "coin98" }, - "COPE": { "api": "cope", "market": "cope" }, - "CUSD": { "api": "celo-dollar", "market": "celo-dollar" }, - "DOGE": { "api": "dogecoin", "market": "dogecoin" }, - "DOT": { "api": "polkadot", "market": "polkadot" }, - "ETH": { "api": "ethereum", "market": "ethereum" }, - "FIDA": { "api": "bonfida", "market": "bonfida" }, - "FTM": { "api": "fantom", "market": "fantom" }, - "FTT": { "api": "ftx-token", "market": "ftx-token" }, - "GMT": { "api": "stepn", "market": "stepn" }, - "GOFX": { "api": "goosefx", "market": "goosefx" }, - "HXRO": { "api": "hxro", "market": "hxro" }, - "INJ": { "api": "injective-protocol", "market": "injective" }, - "JET": { "api": "jet", "market": "jet" }, - "LTC": { "api": "litecoin", "market": "litecoin" }, - "LUNA": { "api": "terra-luna-2", "market": "terra" }, - "LUNC": { "api": "terra-luna", "market": "terra-luna-classic" }, - "MATIC": { "api": "matic-network", "market": "polygon" }, - "MER": { "api": "mercurial", "market": "mercurial" }, - "MIR": { "api": "mirror-protocol", "market": "mirror-protocol" }, - "MNGO": { "api": "mango-markets", "market": "mango" }, - "MSOL": { "api": "msol", "market": "marinade-staked-sol" }, - "NEAR": { "api": "near", "market": "near" }, - "ONE": { "api": "harmony", "market": "harmony" }, - "ORCA": { "api": "orca", "market": "orca" }, - "PAI": { "api": "parrot-usd", "market": "parrot-usd" }, - "PORT": { "api": "port-finance", "market": "port-finance" }, - "RAY": { "api": "raydium", "market": "raydium" }, - "SBR": { "api": "saber", "market": "saber" }, - "SCNSOL": { "api": "socean-staked-sol", "market": "socean-staked-sol" }, - "SLND": { "api": "solend", "market": "solend" }, - "SNY": { "api": "synthetify-token", "market": "synthetify-token" }, - "SOL": { "api": "solana", "market": "solana" }, - "SRM": { "api": "serum", "market": "serum" }, - "STEP": { "api": "step-finance", "market": "step-finance" }, - "STSOL": { "api": "lido-staked-sol", "market": "lido-staked-sol" }, - "TUSD": { "api": "true-usd", "market": "true-usd" }, - "USDC": { "api": "usd-coin", "market": "usd-coin" }, - "USDT": { "api": "tether", "market": "tether" }, - "USTC": { "api": "terrausd", "market": "terraclassicusd" }, - "VAI": { "api": "vai", "market": "vai" }, - "XVS": { "api": "venus", "market": "venus" }, - "ZBC": { "api": "zebec-protocol", "market": "zebec-protocol" }, -} + "Crypto.0G/USD": "zero-gravity", + "Crypto.1INCH/USD": "1inch", + "Crypto.2Z/USD": "doublezero", + "Crypto.4/USD": "4-2", + "Crypto.A/USD": "vaulta", + "Crypto.AAPLX/USD": "apple-xstock", + "Crypto.AAVE/USD": "aave", + "Crypto.ACT/USD": "act-i-the-ai-prophecy", + "Crypto.ADA/USD": "cardano", + "Crypto.AERGO/USD": "aergo", + "Crypto.AERO/USD": "aerodrome-finance", + "Crypto.AEVO/USD": "aevo-exchange", + "Crypto.AFSUI/USD": "aftermath-staked-sui", + "Crypto.AI16Z/USD": "ai16z", + "Crypto.AIXBT/USD": "aixbt", + "Crypto.AKT/USD": "akash-network", + "Crypto.ALGO/USD": "algorand", + "Crypto.ALICE/USD": "alice", + "Crypto.ALKIMI/USD": "alkimi-2", + "Crypto.ALT/USD": "altlayer", + "Crypto.AMI/USD": "ami", + "Crypto.AMP/USD": "amp-token", + "Crypto.ANIME/USD": "anime", + "Crypto.ANKR/USD": "ankr", + "Crypto.ANON/USD": "anon-2", + "Crypto.APE/USD": "apecoin", + "Crypto.APEX/USD": "apex-token-2", + "Crypto.API3/USD": "api3", + "Crypto.APT/USD": "aptos", + "Crypto.AR/USD": "arweave", + "Crypto.ARB/USD": "arbitrum", + "Crypto.ARC/USD": "ai-rig-complex", + "Crypto.ARKM/USD": "arkham", + "Crypto.ASTER/USD": "aster-2", + "Crypto.ASTR/USD": "astar", + "Crypto.ATH/USD": "aethir", + "Crypto.ATLAS/USD": "star-atlas", + "Crypto.ATOM/USD": "cosmos", + "Crypto.AUDD/USD": "novatti-australian-digital-dollar", + "Crypto.AUDIO/USD": "audius", + "Crypto.AURORA/USD": "aurora-near", + "Crypto.AUSD/USD": "agora-dollar", + "Crypto.AVAIL/USD": "avail", + "Crypto.AVAX/USD": "avalanche-2", + "Crypto.AVNT/USD": "avantis", + "Crypto.AXL/USD": "axelar", + "Crypto.AXS/USD": "axie-infinity", + "Crypto.B3/USD": "b3", + "Crypto.BABY/USD": "babylon", + "Crypto.BABYDOGE/USD": "baby-doge-coin", + "Crypto.BAL/USD": "balancer", + "Crypto.BAN/USD": "comedian", + "Crypto.BAND/USD": "band-protocol", + "Crypto.BAT/USD": "basic-attention-token", + "Crypto.BBSOL/USD": "bybit-staked-sol", + "Crypto.BCH/USD": "bitcoin-cash", + "Crypto.BELIEVE/USD": "ben-pasternak", + "Crypto.BENJI/USD": "basenji", + "Crypto.BERA/USD": "berachain-bera", + "Crypto.BGB/USD": "bitget-token", + "Crypto.BIO/USD": "bio-protocol", + "Crypto.BITCOIN/USD": "harrypotterobamasonic10inu", + "Crypto.BLAST/USD": "blast", + "Crypto.BLUE/USD": "bluefin", + "Crypto.BLUR/USD": "blur", + "Crypto.BLZE/USD": "solblaze", + "Crypto.BMT/USD": "bubblemaps", + "Crypto.BNB/USD": "binancecoin", + "Crypto.BNSOL/USD": "binance-staked-sol", + "Crypto.BOBA/USD": "boba-network", + "Crypto.BODEN/USD": "jeo-boden", + "Crypto.BOLD/USD": "bold-2", + "Crypto.BOME/USD": "book-of-meme", + "Crypto.BONK/USD": "bonk", + "Crypto.BORG/USD": "swissborg", + "Crypto.BRETT/USD": "brett", + "Crypto.BROCCOLI/USD": "czs-dog", + "Crypto.BSOL/USD": "blazestake-staked-sol", + "Crypto.BSV/USD": "bitcoin-cash-sv", + "Crypto.BTC/USD": "bitcoin", + "Crypto.BTT/USD": "bittorrent", + "Crypto.BUCK/USD": "bucket-protocol-buck-stablecoin", + "Crypto.BUCKET.USDB/USD": "bucket-usd", + "Crypto.BUDDY/USD": "alright-buddy", + "Crypto.BYUSD/USD": "byusd", + "Crypto.C98/USD": "coin98", + "Crypto.CAKE/USD": "pancakeswap-token", + "Crypto.CAMP/USD": "camp-network", + "Crypto.CARV/USD": "carv", + "Crypto.CASH/USD": "cash-2", + "Crypto.CAT/USD": "cat-3", + "Crypto.CBBTC/USD": "coinbase-wrapped-btc", + "Crypto.CBDOGE/USD": "coinbase-wrapped-doge", + "Crypto.CBETH/USD": "coinbase-wrapped-staked-eth", + "Crypto.CBXRP/USD": "coinbase-wrapped-xrp", + "Crypto.CC/USD": "canton-network", + "Crypto.CELO/USD": "celo", + "Crypto.CELR/USD": "celer-network", + "Crypto.CETUS/USD": "cetus-protocol", + "Crypto.CFX/USD": "conflux-token", + "Crypto.CHILLGUY/USD": "chill-guy", + "Crypto.CHR/USD": "chromaway", + "Crypto.CHZ/USD": "chiliz", + "Crypto.CLANKER/USD": "tokenbot-2", + "Crypto.CLOUD/USD": "sanctum-2", + "Crypto.COINX/USD": "coinbase-xstock", + "Crypto.COMP/USD": "compound-governance-token", + "Crypto.COOK/USD": "meth-protocol", + "Crypto.COOKIE/USD": "cookie", + "Crypto.COQ/USD": "coq-inu", + "Crypto.CORE/USD": "core-2", + "Crypto.COW/USD": "cow", + "Crypto.CRCLX/USD": "circle-xstock", + "Crypto.CRO/USD": "crypto-com-chain", + "Crypto.CRV/USD": "curve-dao-token", + "Crypto.CSPR/USD": "casper-network", + "Crypto.CTSI/USD": "cartesi", + "Crypto.CVX/USD": "convex-finance", + "Crypto.DAI/USD": "dai", + "Crypto.DASH/USD": "dash", + "Crypto.DBR/USD": "debridge", + "Crypto.DEEP/USD": "deep", + "Crypto.DEGEN/USD": "degen-base", + "Crypto.DEUSD/USD": "elixir-deusd", + "Crypto.DEXE/USD": "dexe", + "Crypto.DMC/USD": "delorean", + "Crypto.DODO/USD": "dodo", + "Crypto.DOGE/USD": "dogecoin", + "Crypto.DOGINME/USD": "doginme", + "Crypto.DOGS/USD": "dogs-2", + "Crypto.DOLO/USD": "dolomite", + "Crypto.DOT/USD": "polkadot", + "Crypto.DRIFT/USD": "drift-protocol", + "Crypto.DSOL/USD": "drift-staked-sol", + "Crypto.DYDX/USD": "dydx-chain", + "Crypto.DYM/USD": "dymension", + "Crypto.EBTC/USD": "ether-fi-staked-btc", + "Crypto.ECHO/USD": "echo-protocol", + "Crypto.EDU/USD": "edu-coin", + "Crypto.EGLD/USD": "elrond-erd-2", + "Crypto.EIGEN/USD": "eigenlayer", + "Crypto.ELIZAOS/USD": "elizaos", + "Crypto.ELON/USD": "dogelon-mars", + "Crypto.ENA/USD": "ethena", + "Crypto.ENJ/USD": "enjincoin", + "Crypto.ENS/USD": "ethereum-name-service", + "Crypto.ES/USD": "eclipse-3", + "Crypto.ETC/USD": "ethereum-classic", + "Crypto.ETH/USD": "ethereum", + "Crypto.ETHFI/USD": "ether-fi", + "Crypto.ETHW/USD": "ethereum-pow-iou", + "Crypto.EUL/USD": "euler", + "Crypto.EURC/USD": "euro-coin", + "Crypto.EURCV/USD": "societe-generale-forge-eurcv", + "Crypto.EVAA/USD": "evaa-protocol", + "Crypto.EZETH/USD": "renzo-restaked-eth", + "Crypto.F/USD": "synfutures", + "Crypto.FAI/USD": "freysa-ai", + "Crypto.FARTCOIN/USD": "fartcoin", + "Crypto.FDIT/USD": "fidelity-digital-interest-token", + "Crypto.FDUSD/USD": "first-digital-usd", + "Crypto.FET/USD": "fetch-ai", + "Crypto.FEUSD/USD": "felix-feusd", + "Crypto.FF/USD": "falcon-finance-ff", + "Crypto.FIDA/USD": "bonfida", + "Crypto.FIL/USD": "filecoin", + "Crypto.FLOKI/USD": "floki", + "Crypto.FLOW/USD": "flow", + "Crypto.FLR/USD": "flare-networks", + "Crypto.FLUID/USD": "instadapp", + "Crypto.FORM/USD": "four", + "Crypto.FOXY/USD": "foxy", + "Crypto.FRAG/USD": "fragmetric", + "Crypto.FRAX/USD": "frax", + "Crypto.FRXETH/USD": "frax-ether", + "Crypto.FRXUSD/USD": "frax-usd", + "Crypto.FTT/USD": "ftx-token", + "Crypto.FUEL/USD": "fuel-network", + "Crypto.FWOG/USD": "fwog", + "Crypto.G/USD": "g-2", + "Crypto.GALA/USD": "gala", + "Crypto.GHO/USD": "gho", + "Crypto.GIGA/USD": "gigachad-2", + "Crypto.GLM/USD": "golem", + "Crypto.GLMR/USD": "moonbeam", + "Crypto.GMT/USD": "stepn", + "Crypto.GMX/USD": "gmx", + "Crypto.GNO/USD": "gnosis", + "Crypto.GNS/USD": "gains-network", + "Crypto.GOAT/USD": "goat", + "Crypto.GOGLZ/USD": "googles", + "Crypto.GOLD/USD": "gold-2", + "Crypto.GOOGLX/USD": "alphabet-xstock", + "Crypto.GORK/USD": "gork", + "Crypto.GP/USD": "graphite-protocol", + "Crypto.GPS/USD": "goplus-security", + "Crypto.GRAIL/USD": "camelot-token", + "Crypto.GRASS/USD": "grass", + "Crypto.GRIFFAIN/USD": "griffain", + "Crypto.GRT/USD": "the-graph", + "Crypto.GT/USD": "gatechain-token", + "Crypto.GUSD/USD": "gemini-dollar", + "Crypto.H/USD": "humanity", + "Crypto.HAEDAL/USD": "haedal", + "Crypto.HASUI/USD": "haedal-staked-sui", + "Crypto.HBAR/USD": "hedera-hashgraph", + "Crypto.HEMI/USD": "hemi", + "Crypto.HFT/USD": "hashflow", + "Crypto.HFUN/USD": "hypurr-fun", + "Crypto.HIPPO/USD": "sudeng", + "Crypto.HNT/USD": "helium", + "Crypto.HOLO/USD": "holoworld", + "Crypto.HONEY/USD": "honey-3", + "Crypto.HOODX/USD": "robinhood-xstock", + "Crypto.HT/USD": "huobi-token", + "Crypto.HUMA/USD": "huma-finance", + "Crypto.HYPE/USD": "hyperliquid", + "Crypto.HYPER/USD": "hyperlane", + "Crypto.HYPERSTABLE.USH/USD": "hyperstable", + "Crypto.IBERA/USD": "infrared-bera", + "Crypto.IBGT/USD": "infrafred-bgt", + "Crypto.ICP/USD": "internet-computer", + "Crypto.ICX/USD": "icon", + "Crypto.IDEX/USD": "aurora-dao", + "Crypto.IKA/USD": "ika", + "Crypto.ILV/USD": "illuvium", + "Crypto.IMX/USD": "immutable-x", + "Crypto.INF/USD": "socean-staked-sol", + "Crypto.INIT/USD": "initia", + "Crypto.INJ/USD": "injective-protocol", + "Crypto.IO/USD": "io", + "Crypto.IOTA/USD": "iota", + "Crypto.IOTX/USD": "iotex", + "Crypto.IP/USD": "story-2", + "Crypto.JASMY/USD": "jasmycoin", + "Crypto.JITOSOL/USD": "jito-staked-sol", + "Crypto.JLP/USD": "jupiter-perpetuals-liquidity-provider-token", + "Crypto.JOE/USD": "joe", + "Crypto.JTO/USD": "jito-governance-token", + "Crypto.JUP/USD": "jupiter", + "Crypto.KAIA/USD": "kaia", + "Crypto.KAITO/USD": "kaito", + "Crypto.KAPT/USD": "kofi-aptos", + "Crypto.KAS/USD": "kaspa", + "Crypto.KAVA/USD": "kava", + "Crypto.KCS/USD": "kucoin-shares", + "Crypto.KERNEL/USD": "kernel-2", + "Crypto.KHYPE/USD": "kinetic-staked-hype", + "Crypto.KMNO/USD": "kamino", + "Crypto.KNC/USD": "kyber-network-crystal", + "Crypto.KNTQ/USD": "kinetiq", + "Crypto.KSM/USD": "kusama", + "Crypto.KTA/USD": "keeta", + "Crypto.LA/USD": "lagrange", + "Crypto.LAYER/USD": "solayer", + "Crypto.LBGT/USD": "liquid-bgt", + "Crypto.LBTC/USD": "lombard-staked-btc", + "Crypto.LDO/USD": "lido-dao", + "Crypto.LEO/USD": "leo-2", + "Crypto.LHYPE/USD": "looped-hype", + "Crypto.LINEA/USD": "linea", + "Crypto.LINK/USD": "chainlink", + "Crypto.LION/USD": "loaded-lions", + "Crypto.LL/USD": "lightlink", + "Crypto.LMTS/USD": "limitless-3", + "Crypto.LOFI/USD": "lofi-2", + "Crypto.LOOKS/USD": "looksrare", + "Crypto.LQTY/USD": "liquity", + "Crypto.LRC/USD": "loopring", + "Crypto.LST/USD": "liquid-staking-token", + "Crypto.LTC/USD": "litecoin", + "Crypto.LUCE/USD": "offcial-mascot-of-the-holy-year", + "Crypto.LUNA/USD": "terra-luna-2", + "Crypto.LUNC/USD": "terra-luna", + "Crypto.LUSD/USD": "liquity-usd", + "Crypto.MAG7-SSI/USD": "mag7-ssi", + "Crypto.MANA/USD": "decentraland", + "Crypto.MANEKI/USD": "maneki", + "Crypto.MANTA/USD": "manta-network", + "Crypto.MASK/USD": "mask-network", + "Crypto.MAV/USD": "maverick-protocol", + "Crypto.MCDX/USD": "mcdonald-s-xstock", + "Crypto.ME/USD": "magic-eden", + "Crypto.MELANIA/USD": "melania-meme", + "Crypto.MEME/USD": "memecoin-2", + "Crypto.MERL/USD": "merlin-chain", + "Crypto.MET/USD": "meteora", + "Crypto.META/USD": "meta-2-2", + "Crypto.METASTABLE.MUSD/USD": "mad-usd", + "Crypto.METAX/USD": "meta-xstock", + "Crypto.METH/USD": "mantle-staked-ether", + "Crypto.METIS/USD": "metis-token", + "Crypto.MEW/USD": "cat-in-a-dogs-world", + "Crypto.MEZO.MUSD/USD": "mezo-usd", + "Crypto.MHYPE/USD": "hyperpie-staked-mhype", + "Crypto.MIM/USD": "magic-internet-money", + "Crypto.MINA/USD": "mina-protocol", + "Crypto.MMT/USD": "momentum-3", + "Crypto.MNDE/USD": "marinade", + "Crypto.MNT/USD": "mantle", + "Crypto.MOBILE/USD": "helium-mobile", + "Crypto.MOBY/USD": "moby", + "Crypto.MODE/USD": "mode", + "Crypto.MOG/USD": "mog", + "Crypto.MON/USD": "monad", + "Crypto.MOODENG/USD": "moo-deng", + "Crypto.MORPHO/USD": "morpho", + "Crypto.MOTHER/USD": "mother-iggy", + "Crypto.MOVE/USD": "movement", + "Crypto.MSETH/USD": "metronome-synth-eth", + "Crypto.MSOL/USD": "msol", + "Crypto.MSTRX/USD": "microstrategy-xstock", + "Crypto.MSUSD/USD": "main-street-usd", + "Crypto.MTRG/USD": "meter", + "Crypto.MUBARAK/USD": "mubarak", + "Crypto.MYRO/USD": "myro", + "Crypto.MYX/USD": "myx-finance", + "Crypto.NAVX/USD": "navi", + "Crypto.NEAR/USD": "near", + "Crypto.NECT/USD": "nectar", + "Crypto.NEIRO/USD": "neiro", + "Crypto.NEON/USD": "neon", + "Crypto.NEXO/USD": "nexo", + "Crypto.NFLXX/USD": "netflix-xstock", + "Crypto.NIL/USD": "nillion", + "Crypto.NOBODY/USD": "nobody-sausage", + "Crypto.NOT/USD": "notcoin", + "Crypto.NS/USD": "nodestats", + "Crypto.NTRN/USD": "neutron-3", + "Crypto.NVDAX/USD": "nvidia-xstock", + "Crypto.NXPC/USD": "nexpace", + "Crypto.ODOS/USD": "odos", + "Crypto.OG/USD": "og-fan-token", + "Crypto.OGN/USD": "origin-protocol", + "Crypto.OHM/USD": "olympus", + "Crypto.OKB/USD": "okb", + "Crypto.OM/USD": "mantra-dao", + "Crypto.OMI/USD": "ecomi", + "Crypto.ONDO/USD": "ondo-finance", + "Crypto.ONE/USD": "harmony", + "Crypto.OP/USD": "optimism", + "Crypto.ORCA/USD": "orca", + "Crypto.ORDER/USD": "order-2", + "Crypto.ORDI/USD": "ordinals", + "Crypto.ORE/USD": "ore", + "Crypto.OS/USD": "origin-staked-s", + "Crypto.OSMO/USD": "osmosis", + "Crypto.OUSDT/USD": "openusdt", + "Crypto.P33/USD": "pharaoh-liquid-staking-token", + "Crypto.PARTI/USD": "particle-network", + "Crypto.PAXG/USD": "pax-gold", + "Crypto.PENDLE/USD": "pendle", + "Crypto.PENGU/USD": "penguiana", + "Crypto.PEOPLE/USD": "constitutiondao", + "Crypto.PEPE/USD": "pepe", + "Crypto.PERP/USD": "perpetual-protocol", + "Crypto.PI/USD": "pi-network", + "Crypto.PLUME/USD": "plume", + "Crypto.PNUT/USD": "peanut-3", + "Crypto.POL/USD": "polygon-ecosystem-token", + "Crypto.PONKE/USD": "ponke", + "Crypto.POPCAT/USD": "popcat", + "Crypto.PRCL/USD": "parcl", + "Crypto.PRIME/USD": "echelon-prime", + "Crypto.PROMPT/USD": "wayfinder", + "Crypto.PROVE/USD": "succinct", + "Crypto.PSG/USD": "paris-saint-germain-fan-token", + "Crypto.PUMP/USD": "pump", + "Crypto.PURR/USD": "purr-2", + "Crypto.PYTH/USD": "pyth-network", + "Crypto.PYUSD/USD": "paypal-usd", + "Crypto.QNT/USD": "quant-network", + "Crypto.QQQX/USD": "nasdaq-xstock", + "Crypto.QTUM/USD": "qtum", + "Crypto.RAY/USD": "raydium", + "Crypto.RDNT/USD": "radiant-capital", + "Crypto.RED/USD": "redstone-oracles", + "Crypto.RENDER/USD": "render-token", + "Crypto.RESOLV/USD": "resolv", + "Crypto.RETARDIO/USD": "retardio", + "Crypto.RETH/USD": "rocket-pool-eth", + "Crypto.REX33/USD": "etherex-liquid-staking-token", + "Crypto.REZ/USD": "renzo", + "Crypto.RHEA/USD": "rhea-2", + "Crypto.RION/USD": "hyperion-2", + "Crypto.RLB/USD": "rollbit-coin", + "Crypto.RLP/USD": "resolv-rlp", + "Crypto.RLUSD/USD": "ripple-usd", + "Crypto.RON/USD": "ronin", + "Crypto.ROSE/USD": "oasis-network", + "Crypto.RPL/USD": "rocket-pool", + "Crypto.RSETH/USD": "kelp-dao-restaked-eth", + "Crypto.RSR/USD": "reserve-rights-token", + "Crypto.RSWETH/USD": "restaked-swell-eth", + "Crypto.RUNE/USD": "thorchain", + "Crypto.S/USD": "sonic-3", + "Crypto.SAFE/USD": "safe", + "Crypto.SAMO/USD": "samoyedcoin", + "Crypto.SAND/USD": "the-sandbox", + "Crypto.SATS/USD": "sats-ordinals", + "Crypto.SCA/USD": "scallop-2", + "Crypto.SCETH/USD": "rings-sc-eth", + "Crypto.SCR/USD": "scroll", + "Crypto.SCRT/USD": "secret", + "Crypto.SCUSD/USD": "rings-scusd", + "Crypto.SD/USD": "stader", + "Crypto.SDAI/USD": "savings-dai", + "Crypto.SEDA/USD": "seda-2", + "Crypto.SEI/USD": "sei-network", + "Crypto.SEND/USD": "suilend", + "Crypto.SFRXETH/USD": "staked-frax-ether", + "Crypto.SHADOW/USD": "shadow-2", + "Crypto.SHIB/USD": "shiba-inu", + "Crypto.SIGN/USD": "sign-global", + "Crypto.SKATE/USD": "skate", + "Crypto.SKI/USD": "ski-mask-dog", + "Crypto.SKL/USD": "skale", + "Crypto.SKY/USD": "sky", + "Crypto.SLP/USD": "smooth-love-potion", + "Crypto.SNX/USD": "havven", + "Crypto.SOL/USD": "solana", + "Crypto.SOLV/USD": "solv-protocol", + "Crypto.SOLVBTC/USD": "solv-btc", + "Crypto.SONIC/USD": "sonic-2", + "Crypto.SOON/USD": "soon-2", + "Crypto.SOPH/USD": "sophon", + "Crypto.SPELL/USD": "spell-token", + "Crypto.SPK/USD": "spark-2", + "Crypto.SPX6900/USD": "based-spx6900", + "Crypto.SPYX/USD": "sp500-xstock", + "Crypto.STBL/USD": "stbl", + "Crypto.STETH/USD": "staked-ether", + "Crypto.STG/USD": "stargate-finance", + "Crypto.STHYPE/USD": "staked-hype", + "Crypto.STONE/USD": "stakestone-ether", + "Crypto.STORJ/USD": "storj", + "Crypto.STREAM/USD": "streamflow", + "Crypto.STRK/USD": "starknet", + "Crypto.STS/USD": "beets-staked-sonic", + "Crypto.STSUI/USD": "alphafi-staked-sui", + "Crypto.STX/USD": "blockstack", + "Crypto.SUI/USD": "sui", + "Crypto.SUN/USD": "sun-token", + "Crypto.SUSDE/USD": "ethena-staked-usde", + "Crypto.SUSHI/USD": "sushi", + "Crypto.SWARMS/USD": "swarms", + "Crypto.SWETH/USD": "sweth", + "Crypto.SXP/USD": "swipe", + "Crypto.SYN/USD": "synapse-2", + "Crypto.SYRUP/USD": "syrup", + "Crypto.TAC/USD": "tac", + "Crypto.TAIKO/USD": "taiko", + "Crypto.TAO/USD": "bittensor", + "Crypto.TBTC/USD": "tbtc", + "Crypto.THAPT/USD": "thala-apt", + "Crypto.THE/USD": "thena", + "Crypto.THETA/USD": "theta-token", + "Crypto.THL/USD": "thala", + "Crypto.TIA/USD": "celestia", + "Crypto.TNSR/USD": "tensor", + "Crypto.TOKEN/USD": "tokenfi", + "Crypto.TON/USD": "the-open-network", + "Crypto.TOSHI/USD": "toshi", + "Crypto.TRB/USD": "tellor", + "Crypto.TRUMP/USD": "official-trump", + "Crypto.TRX/USD": "tron", + "Crypto.TSLAX/USD": "tesla-xstock", + "Crypto.TST/USD": "test-3", + "Crypto.TURBO/USD": "turbo", + "Crypto.TURBOS/USD": "turbos-finance", + "Crypto.TUSD/USD": "true-usd", + "Crypto.TUT/USD": "tutorial", + "Crypto.TWT/USD": "trust-wallet-token", + "Crypto.UBTC/USD": "unit-bitcoin", + "Crypto.UETH/USD": "unit-ethereum", + "Crypto.UFART/USD": "unit-fartcoin", + "Crypto.UMA/USD": "uma", + "Crypto.UNI/USD": "uniswap", + "Crypto.UP/USD": "doubleup", + "Crypto.URANUS/USD": "uranus-2", + "Crypto.USD0++/USD": "usd0-liquid-bond", + "Crypto.USD0/USD": "usual-usd", + "Crypto.USDA/USD": "auro-usda", + "Crypto.USDAF/USD": "asymmetry-usdaf-2", + "Crypto.USDAI/USD": "usdai", + "Crypto.USDB/USD": "bucket-usd", + "Crypto.USDC/USD": "usd-coin", + "Crypto.USDD/USD": "usdd", + "Crypto.USDE/USD": "ethena-usde", + "Crypto.USDF/USD": "falcon-finance", + "Crypto.USDG/USD": "global-dollar", + "Crypto.USDH/USD": "hermetica-usdh", + "Crypto.USDHL/USD": "hyper-usd", + "Crypto.USDL/USD": "lift-dollar", + "Crypto.USDN/USD": "smardex-usdn", + "Crypto.USDP/USD": "paxos-standard", + "Crypto.USDS/USD": "usds", + "Crypto.USDT/USD": "tether", + "Crypto.USDT0/USD": "usdt0", + "Crypto.USDTB/USD": "usdtb", + "Crypto.USDU/USD": "uncap-usd", + "Crypto.USDXL/USD": "last-usd", + "Crypto.USDY/USD": "ondo-us-dollar-yield", + "Crypto.USELESS/USD": "useless-3", + "Crypto.USOL/USD": "unit-solana", + "Crypto.USR/USD": "resolv-usr", + "Crypto.USTC/USD": "terrausd", + "Crypto.USUAL/USD": "usual", + "Crypto.USX/USD": "token-dforce-usd", + "Crypto.VANA/USD": "vana", + "Crypto.VELODROME.VELO/USD": "velo", + "Crypto.VET/USD": "vechain", + "Crypto.VIC/USD": "tomochain", + "Crypto.VINE/USD": "vine", + "Crypto.VIRTUAL/USD": "virtual-protocol", + "Crypto.VSUI/USD": "volo-staked-sui", + "Crypto.VVV/USD": "venice-token", + "Crypto.W/USD": "w", + "Crypto.WAGMI/USD": "wagmi-2", + "Crypto.WAL/USD": "walrus-2", + "Crypto.WAVES/USD": "waves", + "Crypto.WBETH/USD": "wrapped-beacon-eth", + "Crypto.WBTC/USD": "wrapped-bitcoin", + "Crypto.WCT/USD": "connect-token-wct", + "Crypto.WEETH/USD": "wrapped-eeth", + "Crypto.WELL/USD": "moonwell-artemis", + "Crypto.WEN/USD": "wen-4", + "Crypto.WETH/USD": "weth", + "Crypto.WFRAGSOL/USD": "wrapped-fragsol", + "Crypto.WIF/USD": "dogwifcoin", + "Crypto.WLD/USD": "worldcoin-wld", + "Crypto.WLFI/USD": "world-liberty-financial", + "Crypto.WOJAK/USD": "wojak", + "Crypto.WOM/USD": "wombat-exchange", + "Crypto.WOO/USD": "woo-network", + "Crypto.WSTETH/USD": "wrapped-steth", + "Crypto.XAI/USD": "xai-blockchain", + "Crypto.XAUT/USD": "tether-gold", + "Crypto.XBTC/USD": "okx-wrapped-btc", + "Crypto.XDC/USD": "xdce-crowd-sale", + "Crypto.XEC/USD": "ecash", + "Crypto.XION/USD": "xion-2", + "Crypto.XLM/USD": "stellar", + "Crypto.XMR/USD": "monero", + "Crypto.XPL/USD": "plasma", + "Crypto.XPRT/USD": "persistence", + "Crypto.XRD/USD": "radix", + "Crypto.XRP/USD": "ripple", + "Crypto.XSGD/USD": "xsgd", + "Crypto.XTZ/USD": "tezos", + "Crypto.YFI/USD": "yearn-finance", + "Crypto.YU/USD": "yu", + "Crypto.YZY/USD": "swasticoin", + "Crypto.ZBTC/USD": "zeus-netwok-zbtc", + "Crypto.ZEC/USD": "zcash", + "Crypto.ZEN/USD": "zencash", + "Crypto.ZEREBRO/USD": "zerebro", + "Crypto.ZETA/USD": "zetachain", + "Crypto.ZEUS/USD": "zeus-2", + "Crypto.ZEX/USD": "zeta", + "Crypto.ZIL/USD": "zilliqa", + "Crypto.ZK/USD": "zksync", + "Crypto.ZORA/USD": "zora", + "Crypto.ZRO/USD": "layerzero" +} \ No newline at end of file From bacee4624dab7aa3ac37781c988498b8e1c5d312 Mon Sep 17 00:00:00 2001 From: Ali Behjati Date: Fri, 28 Nov 2025 13:37:41 +0100 Subject: [PATCH 8/8] chore: bump version --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c6e714f..d5237b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ ignore_missing_imports = true [tool.poetry] name = "pyth-observer" -version = "2.1.4" +version = "3.0.0" description = "Alerts and stuff" authors = [] readme = "README.md" @@ -50,4 +50,4 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.poetry.requires-plugins] -poetry-plugin-export = ">=1.8" \ No newline at end of file +poetry-plugin-export = ">=1.8"