From e16d944985eabb723b9bd2db712d253e02f49fd4 Mon Sep 17 00:00:00 2001 From: choijaewook Date: Fri, 20 Mar 2026 17:56:31 +0900 Subject: [PATCH] feat: Streamlit dashboard with sidebar, detail, portfolio, and main app Co-Authored-By: Claude Sonnet 4.6 --- dashboard/detail.py | 93 +++++++++++++++++++++ dashboard/portfolio_view.py | 70 ++++++++++++++++ dashboard/sidebar.py | 46 +++++++++++ run.py | 161 ++++++++++++++++++++++++++++++++++++ 4 files changed, 370 insertions(+) create mode 100644 dashboard/detail.py create mode 100644 dashboard/portfolio_view.py create mode 100644 dashboard/sidebar.py create mode 100644 run.py diff --git a/dashboard/detail.py b/dashboard/detail.py new file mode 100644 index 0000000..ca51e13 --- /dev/null +++ b/dashboard/detail.py @@ -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) diff --git a/dashboard/portfolio_view.py b/dashboard/portfolio_view.py new file mode 100644 index 0000000..2f5135f --- /dev/null +++ b/dashboard/portfolio_view.py @@ -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") diff --git a/dashboard/sidebar.py b/dashboard/sidebar.py new file mode 100644 index 0000000..2c12d88 --- /dev/null +++ b/dashboard/sidebar.py @@ -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) diff --git a/run.py b/run.py new file mode 100644 index 0000000..04467b6 --- /dev/null +++ b/run.py @@ -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()