"""
Crypto Dashboard — Plotly Edition (clean layout)
• убраны colorbar заголовки (percent_change_*)
• уменьшены отступы KPI
• без глобального Markdown-заголовка
"""
import requests
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from config import CACHE_RETRY_SECONDS, CACHE_TTL_SECONDS
from infrastructure.cache import CacheUnavailableError, TTLCache
from infrastructure.llm_client import llm_service
_coinlore_cache = TTLCache(CACHE_TTL_SECONDS, CACHE_RETRY_SECONDS)
def _load_coinlore() -> pd.DataFrame:
url = "https://api.coinlore.net/api/tickers/"
try:
response = requests.get(url, timeout=20)
response.raise_for_status()
payload = response.json()
data = payload.get("data")
if not isinstance(data, list):
raise ValueError("Unexpected Coinlore payload structure")
except requests.RequestException as exc: # noqa: PERF203 - propagate meaningful message
raise CacheUnavailableError(
"Coinlore API request failed.",
CACHE_RETRY_SECONDS,
) from exc
except ValueError as exc:
raise CacheUnavailableError(
"Coinlore API returned unexpected response.",
CACHE_RETRY_SECONDS,
) from exc
df = pd.DataFrame(data)
for col in [
"price_usd",
"market_cap_usd",
"volume24",
"percent_change_1h",
"percent_change_24h",
"percent_change_7d",
]:
df[col] = pd.to_numeric(df[col], errors="coerce")
return df
def fetch_coinlore_data(limit: int = 100) -> pd.DataFrame:
"""Return cached Coinlore data limited to the requested number of rows."""
base = _coinlore_cache.get("coinlore", _load_coinlore)
return base.head(limit).copy()
def _kpi_line(df) -> str:
"""Формирует компактную KPI-строку без лишних пробелов"""
tracked = ["BTC", "ETH", "SOL", "DOGE"]
parts = []
for sym in tracked:
row = df[df["symbol"] == sym]
if row.empty:
continue
price = float(row["price_usd"])
ch = float(row["percent_change_24h"])
arrow = "↑" if ch > 0 else "↓"
color = "#4ade80" if ch > 0 else "#f87171"
parts.append(
f"{sym} ${price:,.0f} "
f"{arrow} {abs(ch):.2f}%"
)
return " , ".join(parts)
def build_crypto_dashboard(top_n=50):
try:
df = fetch_coinlore_data(top_n)
except CacheUnavailableError as e:
wait = int(e.retry_in) + 1
message = f"⚠️ Coinlore API cooling down. Retry in ~{wait} seconds."
return (
_error_figure("Market Composition", message),
_error_figure("Top Movers", message),
_error_figure("Market Cap vs Volume", message),
message,
message,
)
except Exception: # noqa: BLE001 - surface unexpected failures
message = "❌ Failed to load market data. Please try again later."
return (
_error_figure("Market Composition", message),
_error_figure("Top Movers", message),
_error_figure("Market Cap vs Volume", message),
message,
message,
)
# === Treemap ===
fig_treemap = px.treemap(
df,
path=["symbol"],
values="market_cap_usd",
color="percent_change_24h",
color_continuous_scale="RdYlGn",
height=420,
)
fig_treemap.update_layout(
title=None,
template="plotly_dark",
coloraxis_colorbar=dict(title=None), # 🔹 убираем надпись percent_change_24h
margin=dict(l=5, r=5, t=5, b=5),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
)
# === Bar chart (Top gainers) ===
top = df.sort_values("percent_change_24h", ascending=False).head(12)
fig_bar = px.bar(
top,
x="percent_change_24h",
y="symbol",
orientation="h",
color="percent_change_24h",
color_continuous_scale="Blues",
height=320,
)
fig_bar.update_layout(
title=None,
template="plotly_dark",
coloraxis_colorbar=dict(title=None), # 🔹 убираем надпись percent_change_24h
margin=dict(l=40, r=10, t=5, b=18),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
)
# === Scatter (Market Cap vs Volume) ===
bubble_df = df.head(60).copy()
if not bubble_df.empty:
cap = bubble_df["market_cap_usd"].fillna(0).clip(lower=1.0)
rank = cap.rank(pct=True)
sqrt_cap = np.sqrt(cap)
sqrt_min, sqrt_max = float(sqrt_cap.min()), float(sqrt_cap.max())
if sqrt_max - sqrt_min > 0:
sqrt_norm = (sqrt_cap - sqrt_min) / (sqrt_max - sqrt_min)
else:
sqrt_norm = pd.Series(0.0, index=bubble_df.index)
log_cap = np.log1p(cap)
log_min, log_max = float(log_cap.min()), float(log_cap.max())
if log_max - log_min > 0:
log_norm = (log_cap - log_min) / (log_max - log_min)
else:
log_norm = pd.Series(0.0, index=bubble_df.index)
hybrid = 0.55 * rank + 0.30 * sqrt_norm + 0.15 * log_norm
hybrid = np.power(hybrid, 0.85)
bubble_df["bubble_size"] = 10 + (56 - 10) * hybrid
else:
bubble_df["bubble_size"] = 10
fig_bubble = px.scatter(
bubble_df,
x="market_cap_usd",
y="volume24",
size="bubble_size",
color="percent_change_7d",
hover_name="symbol",
log_x=True,
log_y=True,
color_continuous_scale="RdYlGn",
height=320,
)
fig_bubble.update_layout(
title=None,
template="plotly_dark",
coloraxis_colorbar=dict(title=None), # 🔹 убираем надпись percent_change_7d
margin=dict(l=36, r=10, t=5, b=18),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
)
# === LLM summary ===
summary = _ai_summary(df)
kpi_text = _kpi_line(df)
return fig_treemap, fig_bar, fig_bubble, summary, kpi_text
def _ai_summary(df):
timestamp = pd.Timestamp.utcnow().strftime("%Y-%m-%d %H:%M UTC")
leaders = df.sort_values("percent_change_24h", ascending=False).head(3)["symbol"].tolist()
laggards = df.sort_values("percent_change_24h").head(3)["symbol"].tolist()
total_cap = float(df["market_cap_usd"].sum()) if not df.empty else 0.0
total_volume = float(df["volume24"].sum()) if not df.empty else 0.0
btc_cap = float(df.loc[df["symbol"] == "BTC", "market_cap_usd"].sum()) if total_cap else 0.0
btc_dominance = (btc_cap / total_cap * 100) if total_cap else 0.0
snapshot_rows = (
df.sort_values("market_cap_usd", ascending=False)
.head(12)
[["symbol", "price_usd", "percent_change_24h", "percent_change_7d", "volume24"]]
)
lines = []
for row in snapshot_rows.itertuples(index=False):
lines.append(
(
f"{row.symbol}: price ${row.price_usd:,.2f}, "
f"24h {row.percent_change_24h:+.2f}%, "
f"7d {row.percent_change_7d:+.2f}%, "
f"24h volume ${row.volume24:,.0f}"
)
)
snapshot_text = "\n".join(lines)
system_prompt = (
"You are a crypto market strategist receiving a fresh Coinlore snapshot. "
"Use only the provided metrics to deliver an actionable analysis. "
"Do not mention training cutoffs or missing live access—assume the snapshot reflects the current market."
)
user_prompt = f"""
Coinlore snapshot captured at {timestamp}.
Aggregate totals:
- Total market cap (tracked set): ${total_cap:,.0f}
- 24h traded volume: ${total_volume:,.0f}
- BTC dominance: {btc_dominance:.2f}%
Key movers by 24h change:
{snapshot_text or 'No data available.'}
Top gainers (24h): {', '.join(leaders) if leaders else 'n/a'}
Top laggards (24h): {', '.join(laggards) if laggards else 'n/a'}
Provide:
1. Market sentiment and breadth.
2. Liquidity and volatility observations.
3. Short-term outlook and immediate risks, grounded in this snapshot.
"""
text = ""
for delta in llm_service.stream_chat(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
model="meta-llama/Meta-Llama-3.1-8B-Instruct",
):
text += delta
return text
def _error_figure(title: str, message: str) -> go.Figure:
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=360,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
)
return fig