""" 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}%%{fullData.name}" 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