feat: Streamlit dashboard with sidebar, detail, portfolio, and main app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
93
dashboard/detail.py
Normal file
93
dashboard/detail.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import streamlit as st
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
from plotly.subplots import make_subplots
|
||||||
|
import pandas as pd
|
||||||
|
import ta
|
||||||
|
|
||||||
|
def render_detail(symbol: str, coin_data: dict, ohlcv_df: pd.DataFrame,
|
||||||
|
news_articles: list, social_sentiment: dict, ai_summary: str):
|
||||||
|
symbol_short = symbol.replace("USDT", "")
|
||||||
|
signal = coin_data.get("signal", "HOLD")
|
||||||
|
|
||||||
|
st.header(f"{symbol_short}/USDT")
|
||||||
|
cols = st.columns(5)
|
||||||
|
cols[0].metric("Signal", signal)
|
||||||
|
cols[1].metric("Score", f"{coin_data.get('composite', 0):.0f}/100")
|
||||||
|
cols[2].metric("Technical", f"{coin_data.get('technical', 0):.0f}")
|
||||||
|
cols[3].metric("News", f"{coin_data.get('news', 0):.0f}")
|
||||||
|
cols[4].metric("Social / AI", f"{coin_data.get('social', 0):.0f} / {coin_data.get('ai', 0):.0f}")
|
||||||
|
|
||||||
|
tab_chart, tab_news, tab_social, tab_ai = st.tabs(["Chart", "News", "Social", "AI Analysis"])
|
||||||
|
|
||||||
|
with tab_chart:
|
||||||
|
_render_chart(ohlcv_df, symbol_short)
|
||||||
|
with tab_news:
|
||||||
|
_render_news(news_articles)
|
||||||
|
with tab_social:
|
||||||
|
_render_social(social_sentiment)
|
||||||
|
with tab_ai:
|
||||||
|
_render_ai(ai_summary)
|
||||||
|
|
||||||
|
def _render_chart(df: pd.DataFrame, symbol: str):
|
||||||
|
if df is None or df.empty:
|
||||||
|
st.info("No chart data available")
|
||||||
|
return
|
||||||
|
df = df.copy()
|
||||||
|
df["rsi"] = ta.momentum.RSIIndicator(df["close"]).rsi()
|
||||||
|
macd = ta.trend.MACD(df["close"])
|
||||||
|
df["macd"] = macd.macd()
|
||||||
|
df["macd_signal"] = macd.macd_signal()
|
||||||
|
bb = ta.volatility.BollingerBands(df["close"])
|
||||||
|
df["bb_high"] = bb.bollinger_hband()
|
||||||
|
df["bb_low"] = bb.bollinger_lband()
|
||||||
|
df["sma20"] = df["close"].rolling(20).mean()
|
||||||
|
|
||||||
|
fig = make_subplots(rows=3, cols=1, shared_xaxes=True, vertical_spacing=0.05,
|
||||||
|
row_heights=[0.6, 0.2, 0.2],
|
||||||
|
subplot_titles=(f"{symbol} Price", "RSI", "MACD"))
|
||||||
|
fig.add_trace(go.Candlestick(x=df["timestamp"], open=df["open"], high=df["high"],
|
||||||
|
low=df["low"], close=df["close"], name="Price"), row=1, col=1)
|
||||||
|
fig.add_trace(go.Scatter(x=df["timestamp"], y=df["bb_high"], line=dict(color="rgba(173,216,230,0.3)"), name="BB Upper"), row=1, col=1)
|
||||||
|
fig.add_trace(go.Scatter(x=df["timestamp"], y=df["bb_low"], line=dict(color="rgba(173,216,230,0.3)"), fill="tonexty", name="BB Lower"), row=1, col=1)
|
||||||
|
fig.add_trace(go.Scatter(x=df["timestamp"], y=df["sma20"], line=dict(color="orange", width=1), name="SMA20"), row=1, col=1)
|
||||||
|
fig.add_trace(go.Scatter(x=df["timestamp"], y=df["rsi"], line=dict(color="purple"), name="RSI"), row=2, col=1)
|
||||||
|
fig.add_hline(y=70, line_dash="dash", line_color="red", row=2, col=1)
|
||||||
|
fig.add_hline(y=30, line_dash="dash", line_color="green", row=2, col=1)
|
||||||
|
fig.add_trace(go.Scatter(x=df["timestamp"], y=df["macd"], line=dict(color="blue"), name="MACD"), row=3, col=1)
|
||||||
|
fig.add_trace(go.Scatter(x=df["timestamp"], y=df["macd_signal"], line=dict(color="red"), name="Signal"), row=3, col=1)
|
||||||
|
fig.update_layout(template="plotly_dark", height=700, showlegend=False, xaxis_rangeslider_visible=False)
|
||||||
|
st.plotly_chart(fig, use_container_width=True)
|
||||||
|
|
||||||
|
def _render_news(articles: list):
|
||||||
|
if not articles:
|
||||||
|
st.info("No recent news for this coin")
|
||||||
|
return
|
||||||
|
for article in articles[:10]:
|
||||||
|
votes = article.get("sentiment_votes", {})
|
||||||
|
pos = votes.get("positive", 0)
|
||||||
|
neg = votes.get("negative", 0)
|
||||||
|
if pos > neg:
|
||||||
|
icon = ":green_circle:"
|
||||||
|
elif neg > pos:
|
||||||
|
icon = ":red_circle:"
|
||||||
|
else:
|
||||||
|
icon = ":white_circle:"
|
||||||
|
st.markdown(f"{icon} **{article['title']}**")
|
||||||
|
st.caption(f"Published: {article.get('published_at', 'N/A')} | +{pos} -{neg}")
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
def _render_social(sentiment: dict):
|
||||||
|
if not sentiment or sentiment.get("total", 0) == 0:
|
||||||
|
st.info("No social data available")
|
||||||
|
return
|
||||||
|
total = sentiment["total"]
|
||||||
|
cols = st.columns(3)
|
||||||
|
cols[0].metric("Positive", f"{sentiment['positive']}/{total}", f"{sentiment['positive']/total*100:.0f}%")
|
||||||
|
cols[1].metric("Negative", f"{sentiment['negative']}/{total}", f"-{sentiment['negative']/total*100:.0f}%")
|
||||||
|
cols[2].metric("Neutral", f"{sentiment['neutral']}/{total}")
|
||||||
|
|
||||||
|
def _render_ai(summary: str):
|
||||||
|
if not summary:
|
||||||
|
st.info("AI analysis not available")
|
||||||
|
return
|
||||||
|
st.markdown(summary)
|
||||||
70
dashboard/portfolio_view.py
Normal file
70
dashboard/portfolio_view.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import streamlit as st
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
def render_portfolio(portfolio_manager, current_prices: dict, db):
|
||||||
|
st.header("Portfolio Simulator")
|
||||||
|
pv = portfolio_manager.get_portfolio_value(current_prices)
|
||||||
|
|
||||||
|
cols = st.columns(5)
|
||||||
|
cols[0].metric("Initial Capital", f"${portfolio_manager.initial_capital:.2f}")
|
||||||
|
cols[1].metric("Current Value", f"${pv['total_value']:.2f}")
|
||||||
|
pnl_delta = f"{pv['pnl_pct']:+.1f}%"
|
||||||
|
cols[2].metric("Total P&L", f"${pv['total_pnl']:+.2f}", pnl_delta)
|
||||||
|
cols[3].metric("Win Rate", f"{pv['win_rate']:.0f}%")
|
||||||
|
cols[4].metric("Available Cash", f"${pv['cash']:.2f}")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
col_left, col_right = st.columns([3, 2])
|
||||||
|
|
||||||
|
with col_left:
|
||||||
|
st.subheader("Current Holdings")
|
||||||
|
if portfolio_manager.positions:
|
||||||
|
rows = []
|
||||||
|
for sym, pos in portfolio_manager.positions.items():
|
||||||
|
price = current_prices.get(sym, pos["entry_price"])
|
||||||
|
value = pos["quantity"] * price
|
||||||
|
pnl = value - pos["invested_usd"]
|
||||||
|
pnl_pct = (pnl / pos["invested_usd"] * 100) if pos["invested_usd"] > 0 else 0
|
||||||
|
rows.append({
|
||||||
|
"Coin": sym.replace("USDT", ""),
|
||||||
|
"Invested": f"${pos['invested_usd']:.2f}",
|
||||||
|
"Qty": f"{pos['quantity']:.6f}",
|
||||||
|
"Entry": f"${pos['entry_price']:.4f}",
|
||||||
|
"Current": f"${price:.4f}",
|
||||||
|
"P&L": f"${pnl:+.2f} ({pnl_pct:+.1f}%)",
|
||||||
|
})
|
||||||
|
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
|
||||||
|
else:
|
||||||
|
st.info("No open positions")
|
||||||
|
|
||||||
|
with col_right:
|
||||||
|
st.subheader("Allocation")
|
||||||
|
if portfolio_manager.positions:
|
||||||
|
labels = [s.replace("USDT", "") for s in portfolio_manager.positions]
|
||||||
|
values = [p["quantity"] * current_prices.get(s, p["entry_price"])
|
||||||
|
for s, p in portfolio_manager.positions.items()]
|
||||||
|
labels.append("Cash")
|
||||||
|
values.append(pv["cash"])
|
||||||
|
fig = go.Figure(data=[go.Pie(labels=labels, values=values, hole=0.4)])
|
||||||
|
fig.update_layout(template="plotly_dark", height=300, margin=dict(t=20, b=20))
|
||||||
|
st.plotly_chart(fig, use_container_width=True)
|
||||||
|
else:
|
||||||
|
st.info("100% Cash")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
st.subheader("Trade History")
|
||||||
|
if portfolio_manager.trades:
|
||||||
|
trade_rows = []
|
||||||
|
for t in reversed(portfolio_manager.trades[-20:]):
|
||||||
|
trade_rows.append({
|
||||||
|
"Time": t["timestamp"][:16],
|
||||||
|
"Coin": t["coin"].replace("USDT", ""),
|
||||||
|
"Side": t["side"],
|
||||||
|
"Price": f"${t['price']:.4f}",
|
||||||
|
"Amount": f"${t['amount_usd']:.2f}",
|
||||||
|
"Reason": t["reason"],
|
||||||
|
})
|
||||||
|
st.dataframe(pd.DataFrame(trade_rows), use_container_width=True, hide_index=True)
|
||||||
|
else:
|
||||||
|
st.info("No trades yet")
|
||||||
46
dashboard/sidebar.py
Normal file
46
dashboard/sidebar.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import streamlit as st
|
||||||
|
import json
|
||||||
|
from config import DEFAULT_WEIGHTS
|
||||||
|
|
||||||
|
def render_sidebar(latest_results: dict, db):
|
||||||
|
st.sidebar.title("Crypto Signals")
|
||||||
|
page = st.sidebar.radio("View", ["Signals", "Portfolio"], label_visibility="collapsed")
|
||||||
|
st.sidebar.divider()
|
||||||
|
|
||||||
|
if latest_results:
|
||||||
|
coins = sorted(latest_results.values(), key=lambda x: x["composite"], reverse=True)
|
||||||
|
for coin in coins:
|
||||||
|
signal = coin["signal"]
|
||||||
|
color = {"BUY": "green", "HOLD": "orange", "SELL": "red"}.get(signal, "gray")
|
||||||
|
symbol_short = coin["symbol"].replace("USDT", "")
|
||||||
|
label = f":{color}[{signal}] **{symbol_short}** — {coin['composite']:.0f}"
|
||||||
|
if st.sidebar.button(label, key=coin["symbol"], use_container_width=True):
|
||||||
|
st.session_state["selected_coin"] = coin["symbol"]
|
||||||
|
|
||||||
|
st.sidebar.divider()
|
||||||
|
st.sidebar.subheader("Signal Weights")
|
||||||
|
weights = _load_weights(db)
|
||||||
|
new_weights = {}
|
||||||
|
new_weights["technical"] = st.sidebar.slider("Technical", 0.0, 1.0, weights["technical"], 0.05)
|
||||||
|
new_weights["news"] = st.sidebar.slider("News", 0.0, 1.0, weights["news"], 0.05)
|
||||||
|
new_weights["social"] = st.sidebar.slider("Social", 0.0, 1.0, weights["social"], 0.05)
|
||||||
|
new_weights["ai"] = st.sidebar.slider("AI", 0.0, 1.0, weights["ai"], 0.05)
|
||||||
|
|
||||||
|
total = sum(new_weights.values())
|
||||||
|
if abs(total - 1.0) > 0.01:
|
||||||
|
st.sidebar.warning(f"Weights sum: {total:.2f} (must be 1.0)")
|
||||||
|
else:
|
||||||
|
if new_weights != weights:
|
||||||
|
db.save_setting("weights", json.dumps(new_weights))
|
||||||
|
st.session_state["weights_changed"] = True
|
||||||
|
|
||||||
|
return page
|
||||||
|
|
||||||
|
def _load_weights(db) -> dict:
|
||||||
|
raw = db.load_setting("weights")
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return dict(DEFAULT_WEIGHTS)
|
||||||
161
run.py
Normal file
161
run.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import streamlit as st
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
BINANCE_API_KEY, BINANCE_SECRET, CRYPTOPANIC_API_KEY, NEWS_API_KEY,
|
||||||
|
TWITTER_BEARER_TOKEN, REDDIT_CLIENT_ID, REDDIT_SECRET, REDDIT_USER_AGENT,
|
||||||
|
ANTHROPIC_API_KEY, DB_PATH, LOG_DIR, TOP_N_COINS, INITIAL_CAPITAL,
|
||||||
|
ANALYSIS_INTERVAL_MINUTES, DEFAULT_WEIGHTS, SURGE_VOLUME_MULTIPLIER,
|
||||||
|
)
|
||||||
|
from data.db import Database
|
||||||
|
from data.binance_rest import BinanceRestClient
|
||||||
|
from data.binance_ws import BinanceWSClient
|
||||||
|
from data.news_client import NewsClient
|
||||||
|
from data.social_client import SocialClient
|
||||||
|
from agents.ai_analyst import AIAgent
|
||||||
|
from engine.signal import SignalEngine
|
||||||
|
from engine.surge import SurgeDetector
|
||||||
|
from engine.portfolio import PortfolioManager
|
||||||
|
from scheduler.jobs import AnalysisJob, create_scheduler
|
||||||
|
from dashboard.sidebar import render_sidebar
|
||||||
|
from dashboard.detail import render_detail
|
||||||
|
from dashboard.portfolio_view import render_portfolio
|
||||||
|
|
||||||
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
handlers=[
|
||||||
|
RotatingFileHandler(os.path.join(LOG_DIR, "app.log"), maxBytes=10_000_000, backupCount=5),
|
||||||
|
logging.StreamHandler(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
st.set_page_config(page_title="Crypto Signal Dashboard", layout="wide", page_icon="📊")
|
||||||
|
|
||||||
|
if "initialized" not in st.session_state:
|
||||||
|
st.session_state.initialized = False
|
||||||
|
st.session_state.selected_coin = None
|
||||||
|
|
||||||
|
@st.cache_resource
|
||||||
|
def init_services():
|
||||||
|
db = Database(DB_PATH)
|
||||||
|
db.init()
|
||||||
|
binance_rest = BinanceRestClient(BINANCE_API_KEY, BINANCE_SECRET)
|
||||||
|
binance_ws = BinanceWSClient(BINANCE_API_KEY, BINANCE_SECRET)
|
||||||
|
news_client = NewsClient(CRYPTOPANIC_API_KEY, NEWS_API_KEY)
|
||||||
|
social_client = SocialClient(REDDIT_CLIENT_ID, REDDIT_SECRET, REDDIT_USER_AGENT, TWITTER_BEARER_TOKEN)
|
||||||
|
ai_agent = AIAgent(ANTHROPIC_API_KEY)
|
||||||
|
signal_engine = SignalEngine()
|
||||||
|
surge_detector = SurgeDetector(SURGE_VOLUME_MULTIPLIER)
|
||||||
|
portfolio = PortfolioManager(INITIAL_CAPITAL)
|
||||||
|
|
||||||
|
saved_weights = db.load_setting("weights")
|
||||||
|
if saved_weights:
|
||||||
|
try:
|
||||||
|
signal_engine.set_weights(json.loads(saved_weights))
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
monitored = binance_rest.get_top_coins(TOP_N_COINS)
|
||||||
|
except Exception:
|
||||||
|
monitored = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT", "XRPUSDT"]
|
||||||
|
logger.warning("Failed to fetch top coins, using defaults")
|
||||||
|
|
||||||
|
job = AnalysisJob(
|
||||||
|
binance=binance_rest, news_client=news_client,
|
||||||
|
social_client=social_client, ai_agent=ai_agent,
|
||||||
|
signal_engine=signal_engine, surge_detector=surge_detector,
|
||||||
|
portfolio=portfolio, db=db, monitored_coins=monitored,
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler = create_scheduler(job, ANALYSIS_INTERVAL_MINUTES)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
binance_ws.start(monitored[:20])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"WebSocket start failed: {e}")
|
||||||
|
|
||||||
|
job.run_analysis()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"db": db, "binance_rest": binance_rest, "binance_ws": binance_ws,
|
||||||
|
"news_client": news_client, "social_client": social_client,
|
||||||
|
"signal_engine": signal_engine, "portfolio": portfolio,
|
||||||
|
"job": job, "monitored": monitored,
|
||||||
|
}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
services = init_services()
|
||||||
|
db = services["db"]
|
||||||
|
job = services["job"]
|
||||||
|
portfolio = services["portfolio"]
|
||||||
|
binance_rest = services["binance_rest"]
|
||||||
|
binance_ws = services["binance_ws"]
|
||||||
|
news_client = services["news_client"]
|
||||||
|
social_client = services["social_client"]
|
||||||
|
|
||||||
|
current_prices = binance_ws.get_all_prices()
|
||||||
|
if not current_prices:
|
||||||
|
try:
|
||||||
|
current_prices = binance_rest.get_all_prices()
|
||||||
|
except Exception:
|
||||||
|
current_prices = {}
|
||||||
|
|
||||||
|
page = render_sidebar(job.latest_results, db)
|
||||||
|
|
||||||
|
if page == "Signals":
|
||||||
|
selected = st.session_state.get("selected_coin")
|
||||||
|
if selected and selected in job.latest_results:
|
||||||
|
coin_data = job.latest_results[selected]
|
||||||
|
try:
|
||||||
|
ohlcv = binance_rest.get_ohlcv(selected, interval="1h", limit=100)
|
||||||
|
except Exception:
|
||||||
|
ohlcv = None
|
||||||
|
coin_news = news_client.filter_by_coin(news_client._cache, selected)
|
||||||
|
coin_posts = social_client.filter_posts_by_coin(social_client._cache, selected)
|
||||||
|
sentiment = social_client.simple_sentiment(coin_posts)
|
||||||
|
ai_summary = job.ai_summaries.get(selected, "AI analysis not yet available.")
|
||||||
|
render_detail(selected, coin_data, ohlcv, coin_news, sentiment, ai_summary)
|
||||||
|
else:
|
||||||
|
st.title("Crypto Signal Dashboard")
|
||||||
|
st.info("Select a coin from the sidebar to view detailed analysis.")
|
||||||
|
if job.latest_results:
|
||||||
|
_render_overview(job.latest_results)
|
||||||
|
elif page == "Portfolio":
|
||||||
|
render_portfolio(portfolio, current_prices, db)
|
||||||
|
|
||||||
|
def _render_overview(results: dict):
|
||||||
|
import pandas as pd
|
||||||
|
st.subheader("Signal Overview")
|
||||||
|
buy_coins = [r for r in results.values() if r["signal"] == "BUY"]
|
||||||
|
sell_coins = [r for r in results.values() if r["signal"] == "SELL"]
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
st.markdown("### :green[BUY Signals]")
|
||||||
|
if buy_coins:
|
||||||
|
rows = [{"Coin": c["symbol"].replace("USDT",""), "Score": f"{c['composite']:.0f}",
|
||||||
|
"Tech": f"{c['technical']:.0f}", "News": f"{c['news']:.0f}"}
|
||||||
|
for c in sorted(buy_coins, key=lambda x: x["composite"], reverse=True)]
|
||||||
|
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
|
||||||
|
else:
|
||||||
|
st.info("No BUY signals currently")
|
||||||
|
with col2:
|
||||||
|
st.markdown("### :red[SELL Signals]")
|
||||||
|
if sell_coins:
|
||||||
|
rows = [{"Coin": c["symbol"].replace("USDT",""), "Score": f"{c['composite']:.0f}",
|
||||||
|
"Tech": f"{c['technical']:.0f}", "News": f"{c['news']:.0f}"}
|
||||||
|
for c in sorted(sell_coins, key=lambda x: x["composite"])]
|
||||||
|
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
|
||||||
|
else:
|
||||||
|
st.info("No SELL signals currently")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user