FIN_ASSISTANT / presentation /components /visual_comparison.py
QAway-to
Add normalized toggle to visual comparison charts
812d971
"""
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