Spaces:
Sleeping
Sleeping
| """ | |
| Module: visual_comparison.py | |
| Purpose: Interactive crypto pair comparison (Plotly + CoinGecko) | |
| """ | |
| import requests | |
| import pandas as pd | |
| import plotly.graph_objects as go | |
| from config import CACHE_RETRY_SECONDS, CACHE_TTL_SECONDS | |
| from infrastructure.cache import CacheUnavailableError, TTLCache | |
| COINGECKO_API = "https://api.coingecko.com/api/v3" | |
| _history_cache = TTLCache(CACHE_TTL_SECONDS, CACHE_RETRY_SECONDS) | |
| def _asset_label(asset: str) -> str: | |
| """Format asset identifiers for display.""" | |
| return asset.replace("-", " ").title() | |
| def get_coin_history(coin_id: str, days: int = 180): | |
| """Fetch historical market data for given coin from CoinGecko API.""" | |
| def _load(): | |
| url = f"{COINGECKO_API}/coins/{coin_id}/market_chart?vs_currency=usd&days={days}" | |
| r = requests.get(url, timeout=20) | |
| r.raise_for_status() | |
| data = r.json() | |
| df = pd.DataFrame(data["prices"], columns=["timestamp", "price"]) | |
| df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") | |
| return df | |
| return _history_cache.get((coin_id, days), _load) | |
| def build_price_chart( | |
| pair: tuple[str, str], | |
| days: int = 180, | |
| *, | |
| normalized: bool = False, | |
| ): | |
| """Build comparative price chart for selected pair.""" | |
| coin_a, coin_b = pair | |
| try: | |
| df_a = get_coin_history(coin_a, days) | |
| df_b = get_coin_history(coin_b, days) | |
| except CacheUnavailableError as e: | |
| wait = int(e.retry_in) + 1 | |
| return _error_figure( | |
| "Normalized Growth (Index = 1.0)" if normalized else "Price Comparison", | |
| f"API cooling down. Retry in ~{wait} seconds.", | |
| ) | |
| except Exception: # noqa: BLE001 | |
| return _error_figure( | |
| "Normalized Growth (Index = 1.0)" if normalized else "Price Comparison", | |
| "Failed to load data. Please try again later.", | |
| ) | |
| y_title = "Price (USD)" | |
| chart_title = "Price Comparison" | |
| y_a = df_a["price"] | |
| y_b = df_b["price"] | |
| hovertemplate = None | |
| if normalized: | |
| def _normalize(series: pd.Series) -> pd.Series: | |
| first = series.iloc[0] | |
| if pd.isna(first) or first == 0: | |
| return pd.Series([0.0] * len(series), index=series.index) | |
| return ((series / first) - 1) * 100 | |
| y_a = _normalize(df_a["price"]) | |
| y_b = _normalize(df_b["price"]) | |
| y_title = "Relative Growth (%)" | |
| chart_title = "Normalized Growth (Index = 1.0)" | |
| hovertemplate = "%{y:.2f}%<extra>%{fullData.name}</extra>" | |
| fig = go.Figure() | |
| fig.add_trace( | |
| go.Scatter( | |
| x=df_a["timestamp"], | |
| y=y_a, | |
| name=( | |
| f"{_asset_label(coin_a)} / USD" | |
| if not normalized | |
| else f"{_asset_label(coin_a)} Indexed" | |
| ), | |
| line=dict(width=2), | |
| hovertemplate=hovertemplate, | |
| ) | |
| ) | |
| fig.add_trace( | |
| go.Scatter( | |
| x=df_b["timestamp"], | |
| y=y_b, | |
| name=( | |
| f"{_asset_label(coin_b)} / USD" | |
| if not normalized | |
| else f"{_asset_label(coin_b)} Indexed" | |
| ), | |
| line=dict(width=2), | |
| hovertemplate=hovertemplate, | |
| ) | |
| ) | |
| fig.update_layout( | |
| template="plotly_dark", | |
| height=480, | |
| margin=dict(l=40, r=20, t=30, b=40), | |
| xaxis_title="Date", | |
| yaxis_title=y_title, | |
| legend_title="Asset" if not normalized else "Asset (Indexed)", | |
| title=chart_title, | |
| hovermode="x unified", | |
| ) | |
| fig.update_yaxes(ticksuffix="%" if normalized else None) | |
| return fig | |
| def build_comparison_chart( | |
| pair: tuple[str, str], | |
| days: int = 180, | |
| normalized: bool = False, | |
| ): | |
| """Convenience wrapper for the price/normalized comparison chart.""" | |
| return build_price_chart(pair, days=days, normalized=normalized) | |
| def build_volatility_chart(pair: tuple[str, str], days: int = 180): | |
| """Build comparative volatility chart for selected pair.""" | |
| coin_a, coin_b = pair | |
| try: | |
| df_a = get_coin_history(coin_a, days) | |
| df_b = get_coin_history(coin_b, days) | |
| except CacheUnavailableError as e: | |
| wait = int(e.retry_in) + 1 | |
| return _error_figure( | |
| "Volatility Comparison", | |
| f"API cooling down. Retry in ~{wait} seconds.", | |
| ) | |
| except Exception: # noqa: BLE001 | |
| return _error_figure( | |
| "Volatility Comparison", | |
| "Failed to load data. Please try again later.", | |
| ) | |
| df_a["returns"] = df_a["price"].pct_change() * 100 | |
| df_b["returns"] = df_b["price"].pct_change() * 100 | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter( | |
| x=df_a["timestamp"], | |
| y=df_a["returns"], | |
| name=f"{coin_a.upper()} Daily Change (%)", | |
| mode="lines", | |
| line=dict(width=1.6), | |
| )) | |
| fig.add_trace(go.Scatter( | |
| x=df_b["timestamp"], | |
| y=df_b["returns"], | |
| name=f"{coin_b.upper()} Daily Change (%)", | |
| mode="lines", | |
| line=dict(width=1.6), | |
| )) | |
| fig.update_layout( | |
| template="plotly_dark", | |
| height=400, | |
| margin=dict(l=40, r=20, t=30, b=40), | |
| xaxis_title="Date", | |
| yaxis_title="Daily Change (%)", | |
| legend_title="Volatility", | |
| hovermode="x unified", | |
| ) | |
| return fig | |
| def preload_pairs(pairs: list[tuple[str, str]], days: int = 180) -> None: | |
| """Warm up the cache for all coins involved in the provided pairs.""" | |
| coins = {coin for pair in pairs for coin in pair} | |
| for coin in coins: | |
| try: | |
| get_coin_history(coin, days) | |
| except CacheUnavailableError: | |
| continue | |
| except Exception: | |
| continue | |
| def _error_figure(title: str, message: str): | |
| fig = go.Figure() | |
| fig.add_annotation( | |
| text=message, | |
| showarrow=False, | |
| font=dict(color="#ff6b6b", size=16), | |
| xref="paper", | |
| yref="paper", | |
| x=0.5, | |
| y=0.5, | |
| ) | |
| fig.update_layout( | |
| template="plotly_dark", | |
| title=title, | |
| xaxis=dict(visible=False), | |
| yaxis=dict(visible=False), | |
| height=420, | |
| ) | |
| return fig | |