diff --git a/docs/superpowers/plans/2026-03-20-crypto-signal-dashboard.md b/docs/superpowers/plans/2026-03-20-crypto-signal-dashboard.md new file mode 100644 index 0000000..ad905e9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-crypto-signal-dashboard.md @@ -0,0 +1,2496 @@ +# Crypto Signal Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Streamlit dashboard that analyzes Binance spot coins via technical indicators, news, social media, and AI to produce buy/sell signals with a virtual $200 portfolio simulator. + +**Architecture:** Single Streamlit process imports all modules directly. APScheduler runs analysis agents every 15 minutes in a background thread. Binance WebSocket streams real-time prices. SQLite (WAL mode) stores signals, trades, and portfolio state. + +**Tech Stack:** Python 3.11+, Streamlit, APScheduler, python-binance, pandas, ta (technical analysis), plotly, anthropic SDK, praw (Reddit), httpx, SQLite + +**Spec:** `docs/superpowers/specs/2026-03-20-crypto-signal-dashboard-design.md` + +--- + +## File Structure + +``` +crypto_news_trading/ +├── run.py # Streamlit entry point +├── config.py # Settings, env loading, defaults +├── requirements.txt # Dependencies +├── .env.example # Template for API keys +├── .gitignore +│ +├── db/ +│ └── schema.sql # SQLite DDL +│ +├── data/ +│ ├── __init__.py +│ ├── db.py # SQLite manager (WAL mode, CRUD) +│ ├── binance_rest.py # Binance REST: OHLCV, top coins, ticker +│ ├── binance_ws.py # Binance WebSocket: real-time prices +│ ├── news_client.py # CryptoPanic + NewsAPI client +│ └── social_client.py # Reddit (+ optional Twitter) client +│ +├── agents/ +│ ├── __init__.py +│ ├── technical.py # RSI, MACD, BB, volume, patterns → 0-100 +│ ├── news.py # News sentiment → 0-100 +│ ├── social.py # Social sentiment → 0-100 +│ └── ai_analyst.py # Claude analysis → 0-100 + summary +│ +├── engine/ +│ ├── __init__.py +│ ├── signal.py # Score aggregator + BUY/HOLD/SELL classifier +│ ├── surge.py # Volume surge detector +│ └── portfolio.py # Portfolio sim: entry/exit/P&L tracking +│ +├── dashboard/ +│ ├── app.py # Main Streamlit layout, session state init +│ ├── sidebar.py # Coin list with scores +│ ├── detail.py # Chart, news, social, AI panels +│ └── portfolio_view.py # Portfolio summary, holdings, trade history +│ +├── scheduler/ +│ └── jobs.py # APScheduler job definitions +│ +├── tests/ +│ ├── __init__.py +│ ├── conftest.py # sys.path setup for test imports +│ ├── test_db.py +│ ├── test_binance_rest.py +│ ├── test_technical.py +│ ├── test_news_client.py +│ ├── test_news_agent.py +│ ├── test_social_client.py +│ ├── test_social_agent.py +│ ├── test_signal.py +│ ├── test_surge.py +│ └── test_portfolio.py +│ +└── logs/ # Runtime logs (gitignored) +``` + +--- + +## Task 1: Project Setup & Dependencies + +**Files:** +- Create: `requirements.txt` +- Create: `.env.example` +- Create: `.gitignore` +- Create: `config.py` + +- [ ] **Step 1: Create requirements.txt** + +``` +streamlit>=1.30.0 +python-binance>=1.0.19 +pandas>=2.1.0 +ta>=0.11.0 +plotly>=5.18.0 +anthropic>=0.40.0 +praw>=7.7.0 +httpx>=0.27.0 +apscheduler>=3.10.0 +python-dotenv>=1.0.0 +pytest>=8.0.0 +``` + +- [ ] **Step 2: Create .env.example** + +``` +BINANCE_API_KEY= +BINANCE_SECRET= +CRYPTOPANIC_API_KEY= +NEWS_API_KEY= +TWITTER_BEARER_TOKEN= +REDDIT_CLIENT_ID= +REDDIT_SECRET= +REDDIT_USER_AGENT=crypto_signal_bot/1.0 +ANTHROPIC_API_KEY= +``` + +- [ ] **Step 3: Create .gitignore** + +``` +.env +__pycache__/ +*.pyc +*.db +logs/ +.superpowers/ +``` + +- [ ] **Step 4: Create config.py** + +```python +import os +from dotenv import load_dotenv + +load_dotenv() + +# API Keys +BINANCE_API_KEY = os.getenv("BINANCE_API_KEY", "") +BINANCE_SECRET = os.getenv("BINANCE_SECRET", "") +CRYPTOPANIC_API_KEY = os.getenv("CRYPTOPANIC_API_KEY", "") +NEWS_API_KEY = os.getenv("NEWS_API_KEY", "") +TWITTER_BEARER_TOKEN = os.getenv("TWITTER_BEARER_TOKEN", "") +REDDIT_CLIENT_ID = os.getenv("REDDIT_CLIENT_ID", "") +REDDIT_SECRET = os.getenv("REDDIT_SECRET", "") +REDDIT_USER_AGENT = os.getenv("REDDIT_USER_AGENT", "crypto_signal_bot/1.0") +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") + +# Defaults +DB_PATH = os.path.join(os.path.dirname(__file__), "crypto_signals.db") +LOG_DIR = os.path.join(os.path.dirname(__file__), "logs") +TOP_N_COINS = 50 +SURGE_VOLUME_MULTIPLIER = 3.0 # 300% +MAX_POSITIONS = 5 +INITIAL_CAPITAL = 200.0 +STOP_LOSS_PCT = -0.08 +TAKE_PROFIT_1_PCT = 0.15 +TAKE_PROFIT_2_PCT = 0.25 +MIN_POSITION_USD = 15.0 +ANALYSIS_INTERVAL_MINUTES = 15 + +# Default weights +DEFAULT_WEIGHTS = { + "technical": 0.6, + "news": 0.2, + "social": 0.1, + "ai": 0.1, +} +``` + +- [ ] **Step 5: Create tests/conftest.py for import resolution** + +```python +# tests/conftest.py +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +``` + +- [ ] **Step 6: Install dependencies and commit** + +Run: `pip install -r requirements.txt` + +```bash +git add requirements.txt .env.example .gitignore config.py tests/conftest.py +git commit -m "feat: project setup with dependencies and config" +``` + +--- + +## Task 2: Database Layer + +**Files:** +- Create: `db/schema.sql` +- Create: `data/__init__.py` +- Create: `data/db.py` +- Create: `tests/__init__.py` +- Create: `tests/test_db.py` + +- [ ] **Step 1: Create db/schema.sql** + +```sql +CREATE TABLE IF NOT EXISTS signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + coin TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + technical_score REAL DEFAULT 50, + news_score REAL DEFAULT 50, + social_score REAL DEFAULT 50, + ai_score REAL DEFAULT 50, + composite_score REAL DEFAULT 50, + signal TEXT DEFAULT 'HOLD' +); + +CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + coin TEXT NOT NULL, + side TEXT NOT NULL, + price REAL NOT NULL, + quantity REAL NOT NULL, + amount_usd REAL NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + reason TEXT +); + +CREATE TABLE IF NOT EXISTS positions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + coin TEXT NOT NULL, + entry_price REAL NOT NULL, + quantity REAL NOT NULL, + invested_usd REAL NOT NULL, + status TEXT DEFAULT 'OPEN', + opened_at DATETIME DEFAULT CURRENT_TIMESTAMP, + closed_at DATETIME +); + +CREATE TABLE IF NOT EXISTS portfolio ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + total_value REAL NOT NULL, + cash REAL NOT NULL, + pnl REAL NOT NULL, + pnl_pct REAL NOT NULL +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_signals_coin_ts ON signals(coin, timestamp); +CREATE INDEX IF NOT EXISTS idx_positions_status ON positions(status); +CREATE INDEX IF NOT EXISTS idx_trades_coin ON trades(coin); +``` + +- [ ] **Step 2: Write failing tests for db.py** + +```python +# tests/test_db.py +import os +import pytest +from data.db import Database + +@pytest.fixture +def db(tmp_path): + db_path = str(tmp_path / "test.db") + database = Database(db_path) + database.init() + yield database + database.close() + +def test_init_creates_tables(db): + tables = db.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + names = {r[0] for r in tables} + assert "signals" in names + assert "trades" in names + assert "positions" in names + assert "portfolio" in names + assert "settings" in names + +def test_insert_signal(db): + db.insert_signal("BTCUSDT", 75.0, 60.0, 55.0, 70.0, 68.5, "HOLD") + rows = db.get_latest_signals() + assert len(rows) == 1 + assert rows[0]["coin"] == "BTCUSDT" + assert rows[0]["composite_score"] == 68.5 + +def test_insert_trade(db): + db.insert_trade("ETHUSDT", "BUY", 3500.0, 0.01, 35.0, "signal") + trades = db.get_trades() + assert len(trades) == 1 + assert trades[0]["coin"] == "ETHUSDT" + +def test_open_close_position(db): + db.open_position("SOLUSDT", 140.0, 0.5, 70.0) + positions = db.get_open_positions() + assert len(positions) == 1 + db.close_position(positions[0]["id"]) + assert len(db.get_open_positions()) == 0 + +def test_save_load_setting(db): + db.save_setting("weights", '{"technical": 0.7}') + val = db.load_setting("weights") + assert val == '{"technical": 0.7}' + +def test_portfolio_snapshot(db): + db.insert_portfolio_snapshot(210.0, 50.0, 10.0, 5.0) + snaps = db.get_portfolio_history() + assert len(snaps) == 1 + assert snaps[0]["total_value"] == 210.0 +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cd crypto_news_trading && python -m pytest tests/test_db.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'data.db'` + +- [ ] **Step 4: Implement data/db.py** + +```python +# data/__init__.py +# (empty) + +# data/db.py +import sqlite3 +import os + +class Database: + def __init__(self, db_path: str): + self.db_path = db_path + self.conn = None + + def init(self): + self.conn = sqlite3.connect(self.db_path, check_same_thread=False) + self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.execute("PRAGMA busy_timeout=5000") + self.conn.row_factory = sqlite3.Row + schema_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "db", "schema.sql") + with open(schema_path) as f: + self.conn.executescript(f.read()) + + def close(self): + if self.conn: + self.conn.close() + + def execute(self, sql, params=()): + return self.conn.execute(sql, params) + + def insert_signal(self, coin, technical, news, social, ai, composite, signal): + self.conn.execute( + "INSERT INTO signals (coin, technical_score, news_score, social_score, ai_score, composite_score, signal) VALUES (?,?,?,?,?,?,?)", + (coin, technical, news, social, ai, composite, signal), + ) + self.conn.commit() + + def get_latest_signals(self, limit=50): + return self.conn.execute( + "SELECT * FROM signals WHERE id IN (SELECT MAX(id) FROM signals GROUP BY coin) ORDER BY composite_score DESC LIMIT ?", + (limit,), + ).fetchall() + + def insert_trade(self, coin, side, price, quantity, amount_usd, reason): + self.conn.execute( + "INSERT INTO trades (coin, side, price, quantity, amount_usd, reason) VALUES (?,?,?,?,?,?)", + (coin, side, price, quantity, amount_usd, reason), + ) + self.conn.commit() + + def get_trades(self, limit=100): + return self.conn.execute( + "SELECT * FROM trades ORDER BY timestamp DESC LIMIT ?", (limit,) + ).fetchall() + + def open_position(self, coin, entry_price, quantity, invested_usd): + self.conn.execute( + "INSERT INTO positions (coin, entry_price, quantity, invested_usd) VALUES (?,?,?,?)", + (coin, entry_price, quantity, invested_usd), + ) + self.conn.commit() + + def get_open_positions(self): + return self.conn.execute( + "SELECT * FROM positions WHERE status='OPEN'" + ).fetchall() + + def close_position(self, position_id): + self.conn.execute( + "UPDATE positions SET status='CLOSED', closed_at=CURRENT_TIMESTAMP WHERE id=?", + (position_id,), + ) + self.conn.commit() + + def update_position_quantity(self, position_id, new_quantity): + self.conn.execute( + "UPDATE positions SET quantity=? WHERE id=?", + (new_quantity, position_id), + ) + self.conn.commit() + + def insert_portfolio_snapshot(self, total_value, cash, pnl, pnl_pct): + self.conn.execute( + "INSERT INTO portfolio (total_value, cash, pnl, pnl_pct) VALUES (?,?,?,?)", + (total_value, cash, pnl, pnl_pct), + ) + self.conn.commit() + + def get_portfolio_history(self, limit=100): + return self.conn.execute( + "SELECT * FROM portfolio ORDER BY timestamp DESC LIMIT ?", (limit,) + ).fetchall() + + def save_setting(self, key, value): + self.conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?,?)", + (key, value), + ) + self.conn.commit() + + def load_setting(self, key): + row = self.conn.execute( + "SELECT value FROM settings WHERE key=?", (key,) + ).fetchone() + return row["value"] if row else None +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd crypto_news_trading && python -m pytest tests/test_db.py -v` +Expected: All 6 tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add db/ data/ tests/ +git commit -m "feat: database layer with SQLite WAL mode" +``` + +--- + +## Task 3: Binance REST Client + +**Files:** +- Create: `data/binance_rest.py` +- Create: `tests/test_binance_rest.py` + +- [ ] **Step 1: Write tests with mocked responses** + +```python +# tests/test_binance_rest.py +import pytest +from unittest.mock import patch, MagicMock +from data.binance_rest import BinanceRestClient + +@pytest.fixture +def client(): + return BinanceRestClient(api_key="test", api_secret="test") + +def test_get_top_coins_returns_symbols(client): + mock_tickers = [ + {"symbol": "BTCUSDT", "quoteVolume": "1000000"}, + {"symbol": "ETHUSDT", "quoteVolume": "500000"}, + {"symbol": "BTCETH", "quoteVolume": "200000"}, # non-USDT, should be excluded + ] + with patch.object(client.client, "get_ticker", return_value=mock_tickers): + result = client.get_top_coins(limit=2) + assert "BTCUSDT" in result + assert "ETHUSDT" in result + assert "BTCETH" not in result + +def test_get_ohlcv_returns_dataframe(client): + mock_klines = [ + [1700000000000, "40000", "41000", "39000", "40500", "100", + 1700003599999, "4050000", 500, "50", "2025000", "0"] + ] + with patch.object(client.client, "get_klines", return_value=mock_klines): + df = client.get_ohlcv("BTCUSDT", interval="1h", limit=1) + assert len(df) == 1 + assert "close" in df.columns + assert df.iloc[0]["close"] == 40500.0 + +def test_get_all_tickers(client): + mock_prices = [ + {"symbol": "BTCUSDT", "price": "40000.00"}, + {"symbol": "ETHUSDT", "price": "3500.00"}, + ] + with patch.object(client.client, "get_all_tickers", return_value=mock_prices): + result = client.get_all_prices() + assert result["BTCUSDT"] == 40000.0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd crypto_news_trading && python -m pytest tests/test_binance_rest.py -v` +Expected: FAIL + +- [ ] **Step 3: Implement data/binance_rest.py** + +```python +import pandas as pd +from binance.client import Client +import logging + +logger = logging.getLogger(__name__) + +class BinanceRestClient: + def __init__(self, api_key: str, api_secret: str): + self.client = Client(api_key, api_secret) + + def get_top_coins(self, limit: int = 50) -> list[str]: + tickers = self.client.get_ticker() + usdt_pairs = [ + t for t in tickers + if t["symbol"].endswith("USDT") and not t["symbol"].startswith("USDT") + ] + usdt_pairs.sort(key=lambda x: float(x["quoteVolume"]), reverse=True) + return [t["symbol"] for t in usdt_pairs[:limit]] + + def get_ohlcv(self, symbol: str, interval: str = "1h", limit: int = 100) -> pd.DataFrame: + klines = self.client.get_klines(symbol=symbol, interval=interval, limit=limit) + df = pd.DataFrame(klines, columns=[ + "timestamp", "open", "high", "low", "close", "volume", + "close_time", "quote_volume", "trades", "taker_buy_base", + "taker_buy_quote", "ignore", + ]) + for col in ["open", "high", "low", "close", "volume", "quote_volume"]: + df[col] = df[col].astype(float) + df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms") + return df[["timestamp", "open", "high", "low", "close", "volume", "quote_volume"]] + + def get_all_prices(self) -> dict[str, float]: + tickers = self.client.get_all_tickers() + return {t["symbol"]: float(t["price"]) for t in tickers} + + def get_24h_volume(self, symbol: str) -> dict: + ticker = self.client.get_ticker(symbol=symbol) + return { + "volume": float(ticker["volume"]), + "quote_volume": float(ticker["quoteVolume"]), + "price_change_pct": float(ticker["priceChangePercent"]), + } +``` + +- [ ] **Step 4: Run tests, verify pass, commit** + +Run: `cd crypto_news_trading && python -m pytest tests/test_binance_rest.py -v` + +```bash +git add data/binance_rest.py tests/test_binance_rest.py +git commit -m "feat: Binance REST client for OHLCV and top coins" +``` + +--- + +## Task 4: Binance WebSocket Client + +**Files:** +- Create: `data/binance_ws.py` + +- [ ] **Step 1: Implement data/binance_ws.py** + +```python +import threading +import logging +from binance import ThreadedWebsocketManager + +logger = logging.getLogger(__name__) + +class BinanceWSClient: + def __init__(self, api_key: str, api_secret: str): + self.api_key = api_key + self.api_secret = api_secret + self.twm = None + self.prices: dict[str, float] = {} + self._lock = threading.Lock() + + def start(self, symbols: list[str]): + self.twm = ThreadedWebsocketManager( + api_key=self.api_key, api_secret=self.api_secret + ) + self.twm.start() + streams = [s.lower() + "@miniTicker" for s in symbols] + self.twm.start_multiplex_socket( + callback=self._handle_message, streams=streams + ) + logger.info(f"WebSocket started for {len(symbols)} symbols") + + def _handle_message(self, msg): + if msg.get("e") == "error": + logger.error(f"WebSocket error: {msg}") + return + data = msg.get("data", msg) + if "s" in data and "c" in data: + with self._lock: + self.prices[data["s"]] = float(data["c"]) + + def get_price(self, symbol: str) -> float | None: + with self._lock: + return self.prices.get(symbol) + + def get_all_prices(self) -> dict[str, float]: + with self._lock: + return dict(self.prices) + + def update_symbols(self, symbols: list[str]): + if self.twm: + self.stop() + self.start(symbols) + + def stop(self): + if self.twm: + self.twm.stop() + logger.info("WebSocket stopped") +``` + +- [ ] **Step 2: Commit** + +```bash +git add data/binance_ws.py +git commit -m "feat: Binance WebSocket client for real-time prices" +``` + +--- + +## Task 5: News Client + +**Files:** +- Create: `data/news_client.py` +- Create: `tests/test_news_client.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_news_client.py +import pytest +from unittest.mock import patch, AsyncMock, MagicMock +from data.news_client import NewsClient + +@pytest.fixture +def client(): + return NewsClient(cryptopanic_key="test", newsapi_key="test") + +def test_parse_cryptopanic_response(client): + raw = { + "results": [ + {"title": "Bitcoin hits new high", "published_at": "2026-03-20T10:00:00Z", + "currencies": [{"code": "BTC"}], "kind": "news", + "votes": {"positive": 10, "negative": 2}}, + {"title": "ETH upgrade coming", "published_at": "2026-03-20T09:00:00Z", + "currencies": [{"code": "ETH"}], "kind": "news", + "votes": {"positive": 5, "negative": 1}}, + ] + } + articles = client.parse_cryptopanic(raw) + assert len(articles) == 2 + assert articles[0]["coin"] == "BTC" + assert articles[0]["title"] == "Bitcoin hits new high" + +def test_get_news_for_coin_filters(client): + articles = [ + {"coin": "BTC", "title": "BTC news", "sentiment_votes": {"positive": 5, "negative": 1}}, + {"coin": "ETH", "title": "ETH news", "sentiment_votes": {"positive": 3, "negative": 3}}, + ] + btc_news = client.filter_by_coin(articles, "BTC") + assert len(btc_news) == 1 + assert btc_news[0]["coin"] == "BTC" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd crypto_news_trading && python -m pytest tests/test_news_client.py -v` +Expected: FAIL + +- [ ] **Step 3: Implement data/news_client.py** + +```python +import httpx +import logging +from datetime import datetime, timedelta, timezone + +logger = logging.getLogger(__name__) + +class NewsClient: + CRYPTOPANIC_URL = "https://cryptopanic.com/api/free/v1/posts/" + NEWSAPI_URL = "https://newsapi.org/v2/everything" + + def __init__(self, cryptopanic_key: str, newsapi_key: str = ""): + self.cryptopanic_key = cryptopanic_key + self.newsapi_key = newsapi_key + self._cache: list[dict] = [] + self._cache_time: datetime | None = None + + def fetch_cryptopanic(self) -> list[dict]: + try: + resp = httpx.get( + self.CRYPTOPANIC_URL, + params={"auth_token": self.cryptopanic_key, "filter": "hot", "public": "true"}, + timeout=10, + ) + resp.raise_for_status() + return self.parse_cryptopanic(resp.json()) + except Exception as e: + logger.warning(f"CryptoPanic fetch failed: {e}") + return self._cache + + def parse_cryptopanic(self, raw: dict) -> list[dict]: + articles = [] + for item in raw.get("results", []): + currencies = item.get("currencies") or [] + for cur in currencies: + articles.append({ + "coin": cur.get("code", ""), + "title": item.get("title", ""), + "published_at": item.get("published_at", ""), + "kind": item.get("kind", "news"), + "sentiment_votes": item.get("votes", {}), + }) + return articles + + def fetch_newsapi(self, query: str = "cryptocurrency") -> list[dict]: + if not self.newsapi_key: + return [] + try: + since = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d") + resp = httpx.get( + self.NEWSAPI_URL, + params={"q": query, "from": since, "sortBy": "publishedAt", + "apiKey": self.newsapi_key, "language": "en", "pageSize": 50}, + timeout=10, + ) + resp.raise_for_status() + return [ + {"coin": "", "title": a["title"], "published_at": a["publishedAt"], + "kind": "news", "sentiment_votes": {}} + for a in resp.json().get("articles", []) + ] + except Exception as e: + logger.warning(f"NewsAPI fetch failed: {e}") + return [] + + def fetch_all(self) -> list[dict]: + articles = self.fetch_cryptopanic() + self.fetch_newsapi() + self._cache = articles + self._cache_time = datetime.now(timezone.utc) + return articles + + def filter_by_coin(self, articles: list[dict], coin_symbol: str) -> list[dict]: + symbol = coin_symbol.replace("USDT", "") + return [a for a in articles if a["coin"].upper() == symbol.upper()] +``` + +- [ ] **Step 4: Run tests, verify pass, commit** + +Run: `cd crypto_news_trading && python -m pytest tests/test_news_client.py -v` + +```bash +git add data/news_client.py tests/test_news.py +git commit -m "feat: news client for CryptoPanic and NewsAPI" +``` + +--- + +## Task 6: Social Media Client + +**Files:** +- Create: `data/social_client.py` +- Create: `tests/test_social_client.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_social_client.py +import pytest +from unittest.mock import MagicMock, patch +from data.social_client import SocialClient + +def test_analyze_reddit_posts(): + client = SocialClient(reddit_client_id="x", reddit_secret="x", reddit_user_agent="x") + posts = [ + {"title": "Bitcoin is amazing! Going to the moon!", "score": 100, "num_comments": 50}, + {"title": "BTC crash incoming, sell everything now", "score": 20, "num_comments": 10}, + {"title": "Bitcoin steady at 40k, not bad", "score": 50, "num_comments": 30}, + ] + result = client.simple_sentiment(posts) + assert "positive" in result + assert "negative" in result + assert "neutral" in result + assert result["positive"] + result["negative"] + result["neutral"] == len(posts) + +def test_keyword_match(): + client = SocialClient(reddit_client_id="x", reddit_secret="x", reddit_user_agent="x") + posts = [ + {"title": "Bitcoin BTC going up"}, + {"title": "Ethereum news today"}, + {"title": "BTC and ETH analysis"}, + ] + filtered = client.filter_posts_by_coin(posts, "BTC") + assert len(filtered) == 2 +``` + +- [ ] **Step 2: Run tests to verify fail, then implement** + +- [ ] **Step 3: Implement data/social_client.py** + +```python +import logging +from datetime import datetime + +logger = logging.getLogger(__name__) + +# Keyword-based sentiment (simple, no ML dependency) +POSITIVE_WORDS = {"moon", "bullish", "pump", "rally", "amazing", "great", "buy", "long", "up", "high", "profit", "gain", "surge", "breakout"} +NEGATIVE_WORDS = {"crash", "bearish", "dump", "sell", "short", "down", "low", "loss", "scam", "drop", "fear", "panic", "rekt"} + +class SocialClient: + def __init__(self, reddit_client_id: str, reddit_secret: str, reddit_user_agent: str, + twitter_bearer: str = ""): + self.reddit_client_id = reddit_client_id + self.reddit_secret = reddit_secret + self.reddit_user_agent = reddit_user_agent + self.twitter_bearer = twitter_bearer + self._reddit = None + self._cache: list[dict] = [] + + def _get_reddit(self): + if self._reddit is None: + import praw + self._reddit = praw.Reddit( + client_id=self.reddit_client_id, + client_secret=self.reddit_secret, + user_agent=self.reddit_user_agent, + ) + return self._reddit + + def fetch_reddit(self, subreddits=("cryptocurrency", "binance", "CryptoMarkets"), limit=50) -> list[dict]: + try: + reddit = self._get_reddit() + posts = [] + for sub_name in subreddits: + sub = reddit.subreddit(sub_name) + for post in sub.hot(limit=limit): + posts.append({ + "title": post.title, + "score": post.score, + "num_comments": post.num_comments, + "created_utc": post.created_utc, + "subreddit": sub_name, + }) + self._cache = posts + return posts + except Exception as e: + logger.warning(f"Reddit fetch failed: {e}") + return self._cache + + def filter_posts_by_coin(self, posts: list[dict], coin_symbol: str) -> list[dict]: + symbol = coin_symbol.replace("USDT", "").upper() + # Map common names + name_map = {"BTC": ["bitcoin", "btc"], "ETH": ["ethereum", "eth"], + "SOL": ["solana", "sol"], "BNB": ["bnb", "binance coin"], + "XRP": ["xrp", "ripple"], "DOGE": ["doge", "dogecoin"], + "ADA": ["ada", "cardano"], "AVAX": ["avax", "avalanche"]} + keywords = name_map.get(symbol, [symbol.lower()]) + return [p for p in posts if any(kw in p["title"].lower() for kw in keywords)] + + def simple_sentiment(self, posts: list[dict]) -> dict: + positive = 0 + negative = 0 + neutral = 0 + for p in posts: + words = set(p["title"].lower().split()) + pos_count = len(words & POSITIVE_WORDS) + neg_count = len(words & NEGATIVE_WORDS) + if pos_count > neg_count: + positive += 1 + elif neg_count > pos_count: + negative += 1 + else: + neutral += 1 + return {"positive": positive, "negative": negative, "neutral": neutral, "total": len(posts)} +``` + +- [ ] **Step 4: Run tests, verify pass, commit** + +Run: `cd crypto_news_trading && python -m pytest tests/test_social_client.py -v` + +```bash +git add data/social_client.py tests/test_social.py +git commit -m "feat: social media client with Reddit and sentiment analysis" +``` + +--- + +## Task 7: Technical Analysis Agent + +**Files:** +- Create: `agents/__init__.py` +- Create: `agents/technical.py` +- Create: `tests/test_technical.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_technical.py +import pytest +import pandas as pd +import numpy as np +from agents.technical import TechnicalAgent + +def make_ohlcv(n=100, base_price=100.0): + """Generate synthetic OHLCV data.""" + np.random.seed(42) + closes = base_price + np.cumsum(np.random.randn(n) * 2) + df = pd.DataFrame({ + "timestamp": pd.date_range("2026-01-01", periods=n, freq="1h"), + "open": closes - np.random.rand(n), + "high": closes + np.abs(np.random.randn(n) * 2), + "low": closes - np.abs(np.random.randn(n) * 2), + "close": closes, + "volume": np.random.randint(100, 10000, n).astype(float), + "quote_volume": np.random.randint(100000, 1000000, n).astype(float), + }) + return df + +def test_score_returns_0_to_100(): + agent = TechnicalAgent() + df = make_ohlcv(100) + score = agent.analyze(df) + assert 0 <= score <= 100 + +def test_score_with_uptrend(): + agent = TechnicalAgent() + n = 100 + closes = np.linspace(100, 200, n) # strong uptrend + df = pd.DataFrame({ + "timestamp": pd.date_range("2026-01-01", periods=n, freq="1h"), + "open": closes - 1, "high": closes + 2, + "low": closes - 2, "close": closes, + "volume": np.full(n, 5000.0), + "quote_volume": np.full(n, 500000.0), + }) + score = agent.analyze(df) + assert score >= 60 # uptrend should score high + +def test_score_with_downtrend(): + agent = TechnicalAgent() + n = 100 + closes = np.linspace(200, 100, n) # strong downtrend + df = pd.DataFrame({ + "timestamp": pd.date_range("2026-01-01", periods=n, freq="1h"), + "open": closes + 1, "high": closes + 2, + "low": closes - 2, "close": closes, + "volume": np.full(n, 5000.0), + "quote_volume": np.full(n, 500000.0), + }) + score = agent.analyze(df) + assert score <= 40 # downtrend should score low + +def test_insufficient_data_returns_50(): + agent = TechnicalAgent() + df = make_ohlcv(5) # too few candles + score = agent.analyze(df) + assert score == 50 +``` + +- [ ] **Step 2: Run tests to verify fail** + +- [ ] **Step 3: Implement agents/technical.py** + +```python +import pandas as pd +import ta +import logging + +logger = logging.getLogger(__name__) + +class TechnicalAgent: + MIN_CANDLES = 30 + + def analyze(self, df: pd.DataFrame) -> float: + if len(df) < self.MIN_CANDLES: + return 50.0 + + try: + signals = [] + + # RSI (14) + rsi = ta.momentum.RSIIndicator(df["close"], window=14).rsi().iloc[-1] + if rsi < 30: + signals.append(90) # oversold = bullish + elif rsi < 45: + signals.append(70) + elif rsi < 55: + signals.append(50) + elif rsi < 70: + signals.append(35) + else: + signals.append(15) # overbought = bearish + + # MACD + macd_ind = ta.trend.MACD(df["close"]) + macd_line = macd_ind.macd().iloc[-1] + signal_line = macd_ind.macd_signal().iloc[-1] + macd_diff = macd_ind.macd_diff().iloc[-1] + prev_diff = macd_ind.macd_diff().iloc[-2] + if macd_diff > 0 and prev_diff <= 0: + signals.append(85) # bullish crossover + elif macd_diff < 0 and prev_diff >= 0: + signals.append(15) # bearish crossover + elif macd_diff > 0: + signals.append(65) + else: + signals.append(35) + + # Bollinger Bands + bb = ta.volatility.BollingerBands(df["close"]) + bb_high = bb.bollinger_hband().iloc[-1] + bb_low = bb.bollinger_lband().iloc[-1] + price = df["close"].iloc[-1] + bb_pct = (price - bb_low) / (bb_high - bb_low) if (bb_high - bb_low) > 0 else 0.5 + if bb_pct < 0.2: + signals.append(80) # near lower band = bullish + elif bb_pct > 0.8: + signals.append(20) # near upper band = bearish + else: + signals.append(50) + + # SMA trend (20 vs 50) + sma20 = df["close"].rolling(20).mean().iloc[-1] + sma50 = df["close"].rolling(50).mean().iloc[-1] if len(df) >= 50 else sma20 + if price > sma20 > sma50: + signals.append(80) # strong uptrend + elif price > sma20: + signals.append(65) + elif price < sma20 < sma50: + signals.append(20) # strong downtrend + else: + signals.append(40) + + # SMA 200 (long-term trend) + if len(df) >= 200: + sma200 = df["close"].rolling(200).mean().iloc[-1] + if price > sma200: + signals.append(70) + else: + signals.append(30) + + # Volume trend (current vs 20-period average) + vol_avg = df["volume"].rolling(20).mean().iloc[-1] + vol_current = df["volume"].iloc[-1] + vol_ratio = vol_current / vol_avg if vol_avg > 0 else 1.0 + if vol_ratio > 2.0 and price > sma20: + signals.append(80) # high volume + uptrend + elif vol_ratio > 2.0: + signals.append(40) # high volume + downtrend + else: + signals.append(50) + + # OBV (On-Balance Volume) trend + obv = ta.volume.OnBalanceVolumeIndicator(df["close"], df["volume"]).on_balance_volume() + obv_sma = obv.rolling(20).mean() + if obv.iloc[-1] > obv_sma.iloc[-1]: + signals.append(65) + else: + signals.append(35) + + # Candlestick patterns (simplified) + last = df.iloc[-1] + prev = df.iloc[-2] + body = last["close"] - last["open"] + prev_body = prev["close"] - prev["open"] + # Bullish engulfing + if prev_body < 0 and body > 0 and body > abs(prev_body): + signals.append(80) + # Bearish engulfing + elif prev_body > 0 and body < 0 and abs(body) > prev_body: + signals.append(20) + # Doji (indecision) + elif abs(body) < (last["high"] - last["low"]) * 0.1: + signals.append(50) + else: + signals.append(50) + + return round(sum(signals) / len(signals), 1) + + except Exception as e: + logger.error(f"Technical analysis error: {e}") + return 50.0 +``` + +- [ ] **Step 4: Run tests, verify pass, commit** + +Run: `cd crypto_news_trading && python -m pytest tests/test_technical.py -v` + +```bash +git add agents/ tests/test_technical.py +git commit -m "feat: technical analysis agent with RSI, MACD, BB, SMA, volume" +``` + +--- + +## Task 8: News Sentiment Agent + +**Files:** +- Create: `agents/news.py` +- Create: `tests/test_news_agent.py` (rename test file to avoid conflict) + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_news_agent.py +import pytest +from agents.news import NewsAgent + +def test_score_positive_news(): + agent = NewsAgent() + articles = [ + {"title": "Bitcoin surges 10%", "sentiment_votes": {"positive": 10, "negative": 1}}, + {"title": "BTC adoption grows", "sentiment_votes": {"positive": 8, "negative": 2}}, + {"title": "Bitcoin rally continues", "sentiment_votes": {"positive": 15, "negative": 0}}, + ] + score = agent.analyze(articles) + assert score >= 70 + +def test_score_negative_news(): + agent = NewsAgent() + articles = [ + {"title": "Bitcoin crashes hard", "sentiment_votes": {"positive": 1, "negative": 10}}, + {"title": "Crypto market in fear", "sentiment_votes": {"positive": 0, "negative": 15}}, + ] + score = agent.analyze(articles) + assert score <= 35 + +def test_score_no_news_returns_50(): + agent = NewsAgent() + score = agent.analyze([]) + assert score == 50 + +def test_score_mixed_news(): + agent = NewsAgent() + articles = [ + {"title": "BTC up", "sentiment_votes": {"positive": 5, "negative": 5}}, + ] + score = agent.analyze(articles) + assert 40 <= score <= 60 +``` + +- [ ] **Step 2: Implement agents/news.py** + +```python +import logging + +logger = logging.getLogger(__name__) + +POSITIVE_WORDS = {"surge", "rally", "bullish", "high", "gain", "profit", "up", "grows", "adoption", "breakout", "soar", "record"} +NEGATIVE_WORDS = {"crash", "drop", "bearish", "low", "loss", "fear", "down", "dump", "scam", "hack", "ban", "panic", "plunge"} + +class NewsAgent: + def analyze(self, articles: list[dict]) -> float: + if not articles: + return 50.0 + + try: + scores = [] + for article in articles: + vote_score = self._vote_sentiment(article.get("sentiment_votes", {})) + text_score = self._text_sentiment(article.get("title", "")) + # Weighted: votes matter more if available + if vote_score is not None: + scores.append(vote_score * 0.6 + text_score * 0.4) + else: + scores.append(text_score) + + return round(max(0, min(100, sum(scores) / len(scores))), 1) + + except Exception as e: + logger.error(f"News analysis error: {e}") + return 50.0 + + def _vote_sentiment(self, votes: dict) -> float | None: + pos = votes.get("positive", 0) + neg = votes.get("negative", 0) + total = pos + neg + if total == 0: + return None + ratio = pos / total # 0.0 to 1.0 + return ratio * 100 + + def _text_sentiment(self, text: str) -> float: + words = set(text.lower().split()) + pos = len(words & POSITIVE_WORDS) + neg = len(words & NEGATIVE_WORDS) + total = pos + neg + if total == 0: + return 50.0 + return (pos / total) * 100 +``` + +- [ ] **Step 3: Run tests, verify pass, commit** + +Run: `cd crypto_news_trading && python -m pytest tests/test_news_agent.py -v` + +```bash +git add agents/news.py tests/test_news_agent.py +git commit -m "feat: news sentiment agent with vote and text analysis" +``` + +--- + +## Task 9: Social Sentiment Agent + +**Files:** +- Create: `agents/social.py` +- Create: `tests/test_social_agent.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_social_agent.py +import pytest +from agents.social import SocialAgent + +def test_score_bullish_social(): + agent = SocialAgent() + sentiment = {"positive": 8, "negative": 1, "neutral": 1, "total": 10} + mention_trend = 2.5 # mentions up 150% + score = agent.analyze(sentiment, mention_trend) + assert score >= 70 + +def test_score_bearish_social(): + agent = SocialAgent() + sentiment = {"positive": 1, "negative": 8, "neutral": 1, "total": 10} + mention_trend = 0.5 + score = agent.analyze(sentiment, mention_trend) + assert score <= 35 + +def test_no_data_returns_50(): + agent = SocialAgent() + score = agent.analyze({"positive": 0, "negative": 0, "neutral": 0, "total": 0}, 1.0) + assert score == 50 +``` + +- [ ] **Step 2: Implement agents/social.py** + +```python +import logging + +logger = logging.getLogger(__name__) + +class SocialAgent: + def analyze(self, sentiment: dict, mention_trend: float = 1.0) -> float: + total = sentiment.get("total", 0) + if total == 0: + return 50.0 + + try: + pos = sentiment.get("positive", 0) + neg = sentiment.get("negative", 0) + + # Sentiment ratio score (0-100) + ratio = pos / total + sentiment_score = ratio * 100 + + # Mention trend bonus/penalty + # trend > 1 = growing mentions, < 1 = declining + if mention_trend > 2.0: + trend_bonus = 15 + elif mention_trend > 1.5: + trend_bonus = 10 + elif mention_trend > 1.0: + trend_bonus = 5 + elif mention_trend < 0.5: + trend_bonus = -10 + else: + trend_bonus = 0 + + # If sentiment is negative and mentions are surging, it's bearish + if ratio < 0.4 and mention_trend > 1.5: + trend_bonus = -15 + + score = sentiment_score + trend_bonus + return round(max(0, min(100, score)), 1) + + except Exception as e: + logger.error(f"Social analysis error: {e}") + return 50.0 +``` + +- [ ] **Step 3: Run tests, verify pass, commit** + +Run: `cd crypto_news_trading && python -m pytest tests/test_social_agent.py -v` + +```bash +git add agents/social.py tests/test_social_agent.py +git commit -m "feat: social sentiment agent with mention trend analysis" +``` + +--- + +## Task 10: AI Analysis Agent + +**Files:** +- Create: `agents/ai_analyst.py` + +- [ ] **Step 1: Implement agents/ai_analyst.py** + +```python +import json +import logging +from anthropic import Anthropic + +logger = logging.getLogger(__name__) + +class AIAgent: + def __init__(self, api_key: str): + self.client = Anthropic(api_key=api_key) + + def analyze_batch(self, coins_data: list[dict]) -> dict[str, dict]: + """Analyze multiple coins in one API call. Returns {symbol: {score, summary}}.""" + if not coins_data: + return {} + + prompt = self._build_prompt(coins_data) + try: + response = self.client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=2000, + messages=[{"role": "user", "content": prompt}], + ) + return self._parse_response(response.content[0].text, coins_data) + except Exception as e: + logger.error(f"AI analysis failed: {e}") + return {c["symbol"]: {"score": 50, "summary": "Analysis unavailable"} for c in coins_data} + + def _build_prompt(self, coins_data: list[dict]) -> str: + coins_text = "" + for coin in coins_data: + coins_text += f""" +Coin: {coin['symbol']} +- Price: ${coin.get('price', 'N/A')} +- 24h Change: {coin.get('change_pct', 'N/A')}% +- Technical Score: {coin.get('technical_score', 'N/A')}/100 +- News Sentiment: {coin.get('news_score', 'N/A')}/100 +- Social Sentiment: {coin.get('social_score', 'N/A')}/100 +- Recent Headlines: {', '.join(coin.get('headlines', [])[:3])} +""" + return f"""You are a crypto market analyst. Analyze these coins for short-term (24h) spot trading potential. + +For each coin, provide: +1. A score from 0-100 (0=strong sell, 50=neutral, 100=strong buy) +2. A brief 1-2 sentence summary explaining your reasoning + +{coins_text} + +Respond in JSON format: +{{ + "SYMBOL": {{"score": NUMBER, "summary": "TEXT"}}, + ... +}} + +Only output the JSON, no other text.""" + + def _parse_response(self, text: str, coins_data: list[dict]) -> dict[str, dict]: + try: + # Extract JSON from response + text = text.strip() + if text.startswith("```"): + text = text.split("```")[1] + if text.startswith("json"): + text = text[4:] + return json.loads(text) + except (json.JSONDecodeError, IndexError) as e: + logger.warning(f"Failed to parse AI response: {e}") + return {c["symbol"]: {"score": 50, "summary": "Parse error"} for c in coins_data} +``` + +- [ ] **Step 2: Commit** + +```bash +git add agents/ai_analyst.py +git commit -m "feat: AI analysis agent using Claude API" +``` + +--- + +## Task 11: Signal Engine + +**Files:** +- Create: `engine/__init__.py` +- Create: `engine/signal.py` +- Create: `tests/test_signal.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_signal.py +import pytest +from engine.signal import SignalEngine + +@pytest.fixture +def engine(): + return SignalEngine() + +def test_compute_score_default_weights(engine): + score = engine.compute_score(80, 60, 70, 50) + # 80*0.6 + 60*0.2 + 70*0.1 + 50*0.1 = 48+12+7+5 = 72 + assert score == 72.0 + +def test_classify_buy(engine): + assert engine.classify(75) == "BUY" + +def test_classify_hold(engine): + assert engine.classify(55) == "HOLD" + +def test_classify_sell(engine): + assert engine.classify(30) == "SELL" + +def test_custom_weights(engine): + engine.set_weights({"technical": 0.4, "news": 0.3, "social": 0.2, "ai": 0.1}) + score = engine.compute_score(80, 60, 70, 50) + # 80*0.4 + 60*0.3 + 70*0.2 + 50*0.1 = 32+18+14+5 = 69 + assert score == 69.0 + +def test_weights_must_sum_to_one(engine): + with pytest.raises(ValueError): + engine.set_weights({"technical": 0.5, "news": 0.3, "social": 0.1, "ai": 0.2}) + +def test_rank_coins(engine): + coins = { + "BTC": {"technical": 80, "news": 70, "social": 60, "ai": 75}, + "ETH": {"technical": 90, "news": 80, "social": 70, "ai": 85}, + "DOGE": {"technical": 30, "news": 25, "social": 40, "ai": 20}, + } + ranked = engine.rank_coins(coins) + assert ranked[0]["symbol"] == "ETH" + assert ranked[-1]["symbol"] == "DOGE" + assert ranked[-1]["signal"] == "SELL" +``` + +- [ ] **Step 2: Implement engine/signal.py** + +```python +from config import DEFAULT_WEIGHTS + +class SignalEngine: + def __init__(self): + self.weights = dict(DEFAULT_WEIGHTS) + + def set_weights(self, weights: dict): + total = sum(weights.values()) + if abs(total - 1.0) > 0.01: + raise ValueError(f"Weights must sum to 1.0, got {total}") + self.weights = weights + + def compute_score(self, technical: float, news: float, social: float, ai: float) -> float: + score = ( + technical * self.weights["technical"] + + news * self.weights["news"] + + social * self.weights["social"] + + ai * self.weights["ai"] + ) + return round(score, 1) + + def classify(self, score: float) -> str: + if score >= 70: + return "BUY" + elif score >= 40: + return "HOLD" + return "SELL" + + def rank_coins(self, coins: dict[str, dict]) -> list[dict]: + results = [] + for symbol, scores in coins.items(): + composite = self.compute_score( + scores["technical"], scores["news"], scores["social"], scores["ai"] + ) + results.append({ + "symbol": symbol, + "technical": scores["technical"], + "news": scores["news"], + "social": scores["social"], + "ai": scores["ai"], + "composite": composite, + "signal": self.classify(composite), + }) + results.sort(key=lambda x: x["composite"], reverse=True) + return results +``` + +- [ ] **Step 3: Run tests, verify pass, commit** + +Run: `cd crypto_news_trading && python -m pytest tests/test_signal.py -v` + +```bash +git add engine/ tests/test_signal.py +git commit -m "feat: signal engine with weighted scoring and ranking" +``` + +--- + +## Task 12: Surge Detector + +**Files:** +- Create: `engine/surge.py` +- Create: `tests/test_surge.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_surge.py +import pytest +from engine.surge import SurgeDetector + +def test_detect_surge(): + detector = SurgeDetector(multiplier=3.0) + tickers = [ + {"symbol": "BTCUSDT", "quoteVolume": "1000000", "volume": "100"}, + {"symbol": "NEWUSDT", "quoteVolume": "5000000", "volume": "500"}, # surge + {"symbol": "ETHUSDT", "quoteVolume": "800000", "volume": "80"}, + ] + avg_volumes = {"BTCUSDT": 900000, "NEWUSDT": 1000000, "ETHUSDT": 750000} + surged = detector.detect(tickers, avg_volumes) + assert "NEWUSDT" in surged + assert "BTCUSDT" not in surged + +def test_no_surge(): + detector = SurgeDetector(multiplier=3.0) + tickers = [{"symbol": "BTCUSDT", "quoteVolume": "1000000", "volume": "100"}] + avg_volumes = {"BTCUSDT": 900000} + surged = detector.detect(tickers, avg_volumes) + assert len(surged) == 0 +``` + +- [ ] **Step 2: Implement engine/surge.py** + +```python +import logging + +logger = logging.getLogger(__name__) + +class SurgeDetector: + def __init__(self, multiplier: float = 3.0): + self.multiplier = multiplier + + def detect(self, tickers: list[dict], avg_volumes: dict[str, float]) -> list[str]: + surged = [] + for t in tickers: + symbol = t["symbol"] + if not symbol.endswith("USDT"): + continue + current_vol = float(t.get("quoteVolume", 0)) + avg_vol = avg_volumes.get(symbol, 0) + if avg_vol > 0 and current_vol >= avg_vol * self.multiplier: + logger.info(f"Surge detected: {symbol} volume {current_vol:.0f} vs avg {avg_vol:.0f} ({current_vol/avg_vol:.1f}x)") + surged.append(symbol) + return surged +``` + +- [ ] **Step 3: Run tests, verify pass, commit** + +Run: `cd crypto_news_trading && python -m pytest tests/test_surge.py -v` + +```bash +git add engine/surge.py tests/test_surge.py +git commit -m "feat: volume surge detector" +``` + +--- + +## Task 13: Portfolio Simulator + +**Files:** +- Create: `engine/portfolio.py` +- Create: `tests/test_portfolio.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_portfolio.py +import pytest +from engine.portfolio import PortfolioManager + +@pytest.fixture +def pm(): + return PortfolioManager(initial_capital=200.0) + +def test_initial_state(pm): + assert pm.cash == 200.0 + assert pm.positions == {} + assert pm.trades == [] + +def test_buy(pm): + pm.buy("BTCUSDT", price=40000.0, score=85) + assert "BTCUSDT" in pm.positions + assert pm.cash < 200.0 + assert len(pm.trades) == 1 + assert pm.trades[0]["side"] == "BUY" + +def test_buy_size_by_score(pm): + # Score 70-79: 15% of cash = $30 + pm.buy("SOLUSDT", price=140.0, score=75) + assert abs(pm.trades[0]["amount_usd"] - 30.0) < 0.01 + +def test_buy_respects_max_positions(pm): + for i, coin in enumerate(["A", "B", "C", "D", "E"]): + pm.buy(f"{coin}USDT", price=10.0, score=80) + pm.buy("FUSDT", price=10.0, score=80) # 6th should be rejected + assert len(pm.positions) == 5 + +def test_buy_respects_min_position(pm): + pm.cash = 10.0 # below MIN_POSITION_USD=15 + pm.buy("BTCUSDT", price=40000.0, score=85) + assert "BTCUSDT" not in pm.positions + +def test_sell_full(pm): + pm.buy("ETHUSDT", price=3500.0, score=80) + invested = pm.positions["ETHUSDT"]["invested_usd"] + pm.sell("ETHUSDT", price=3800.0, reason="signal") + assert "ETHUSDT" not in pm.positions + assert pm.cash > 200.0 - invested # got money back + profit + +def test_stop_loss(pm): + pm.buy("DOGEUSDT", price=0.10, score=80) + pm.check_exit("DOGEUSDT", current_price=0.091) # -9% < -8% + assert "DOGEUSDT" not in pm.positions + +def test_take_profit_partial(pm): + pm.buy("SOLUSDT", price=100.0, score=80) + qty_before = pm.positions["SOLUSDT"]["quantity"] + pm.check_exit("SOLUSDT", current_price=116.0) # +16% > +15% + assert pm.positions["SOLUSDT"]["quantity"] < qty_before # partial sell + +def test_take_profit_full(pm): + pm.buy("SOLUSDT", price=100.0, score=80) + pm.check_exit("SOLUSDT", current_price=126.0) # +26% > +25% + assert "SOLUSDT" not in pm.positions + +def test_pnl_calculation(pm): + pm.buy("ETHUSDT", price=3500.0, score=80) + pnl = pm.get_portfolio_value({"ETHUSDT": 3800.0}) + assert pnl["total_pnl"] > 0 + assert pnl["total_value"] > 200.0 +``` + +- [ ] **Step 2: Implement engine/portfolio.py** + +```python +import logging +from datetime import datetime, timezone +from config import MAX_POSITIONS, MIN_POSITION_USD, STOP_LOSS_PCT, TAKE_PROFIT_1_PCT, TAKE_PROFIT_2_PCT + +logger = logging.getLogger(__name__) + +class PortfolioManager: + def __init__(self, initial_capital: float = 200.0): + self.initial_capital = initial_capital + self.cash = initial_capital + self.positions: dict[str, dict] = {} # symbol -> {entry_price, quantity, invested_usd, tp1_hit} + self.trades: list[dict] = [] + + def buy(self, symbol: str, price: float, score: float) -> bool: + if symbol in self.positions: + return False + if len(self.positions) >= MAX_POSITIONS: + logger.info(f"Max positions reached, cannot buy {symbol}") + return False + + amount = self._position_size(score) + if amount < MIN_POSITION_USD: + logger.info(f"Position too small ({amount:.2f}), skipping {symbol}") + return False + if amount > self.cash: + amount = self.cash + if amount < MIN_POSITION_USD: + return False + + quantity = amount / price + self.cash -= amount + self.positions[symbol] = { + "entry_price": price, + "quantity": quantity, + "invested_usd": amount, + "tp1_hit": False, + "opened_at": datetime.now(timezone.utc).isoformat(), + } + self.trades.append({ + "coin": symbol, "side": "BUY", "price": price, + "quantity": quantity, "amount_usd": amount, + "timestamp": datetime.now(timezone.utc).isoformat(), "reason": "signal", + }) + logger.info(f"BUY {symbol}: ${amount:.2f} @ ${price:.4f} (qty: {quantity:.6f})") + return True + + def sell(self, symbol: str, price: float, reason: str = "signal", partial: float = 1.0): + if symbol not in self.positions: + return + pos = self.positions[symbol] + sell_qty = pos["quantity"] * partial + sell_usd = sell_qty * price + self.cash += sell_usd + + self.trades.append({ + "coin": symbol, "side": "SELL", "price": price, + "quantity": sell_qty, "amount_usd": sell_usd, + "timestamp": datetime.now(timezone.utc).isoformat(), "reason": reason, + }) + + if partial >= 1.0: + del self.positions[symbol] + logger.info(f"SELL ALL {symbol}: ${sell_usd:.2f} @ ${price:.4f} ({reason})") + else: + pos["quantity"] -= sell_qty + logger.info(f"SELL {partial*100:.0f}% {symbol}: ${sell_usd:.2f} @ ${price:.4f} ({reason})") + + def check_exit(self, symbol: str, current_price: float): + if symbol not in self.positions: + return + pos = self.positions[symbol] + entry = pos["entry_price"] + change_pct = (current_price - entry) / entry + + # Stop-loss + if change_pct <= STOP_LOSS_PCT: + self.sell(symbol, current_price, reason="stop-loss") + return + + # Take-profit 2 (full exit at +25%) + if change_pct >= TAKE_PROFIT_2_PCT: + self.sell(symbol, current_price, reason="take-profit-2") + return + + # Take-profit 1 (50% exit at +15%) + if change_pct >= TAKE_PROFIT_1_PCT and not pos["tp1_hit"]: + pos["tp1_hit"] = True + self.sell(symbol, current_price, reason="take-profit-1", partial=0.5) + + def _position_size(self, score: float) -> float: + if score >= 90: + pct = 0.30 + elif score >= 80: + pct = 0.20 + else: + pct = 0.15 + return round(self.cash * pct, 2) + + def get_portfolio_value(self, current_prices: dict[str, float]) -> dict: + holdings_value = sum( + pos["quantity"] * current_prices.get(sym, pos["entry_price"]) + for sym, pos in self.positions.items() + ) + total_value = self.cash + holdings_value + total_pnl = total_value - self.initial_capital + pnl_pct = (total_pnl / self.initial_capital) * 100 if self.initial_capital > 0 else 0 + + winning = sum(1 for t in self.trades if t["side"] == "SELL" and self._trade_pnl(t) > 0) + total_sells = sum(1 for t in self.trades if t["side"] == "SELL") + win_rate = (winning / total_sells * 100) if total_sells > 0 else 0 + + return { + "total_value": round(total_value, 2), + "cash": round(self.cash, 2), + "holdings_value": round(holdings_value, 2), + "total_pnl": round(total_pnl, 2), + "pnl_pct": round(pnl_pct, 2), + "win_rate": round(win_rate, 1), + "open_positions": len(self.positions), + } + + def _trade_pnl(self, sell_trade: dict) -> float: + # Find most recent buy for this coin before the sell + matching_buys = [ + t for t in self.trades + if t["coin"] == sell_trade["coin"] and t["side"] == "BUY" + and t["timestamp"] <= sell_trade["timestamp"] + ] + if matching_buys: + latest_buy = matching_buys[-1] + return (sell_trade["price"] - latest_buy["price"]) * sell_trade["quantity"] + return 0 +``` + +- [ ] **Step 3: Run tests, verify pass, commit** + +Run: `cd crypto_news_trading && python -m pytest tests/test_portfolio.py -v` + +```bash +git add engine/portfolio.py tests/test_portfolio.py +git commit -m "feat: portfolio simulator with entry/exit rules and P&L tracking" +``` + +--- + +## Task 14: Scheduler + +**Files:** +- Create: `scheduler/__init__.py` (empty) +- Create: `scheduler/jobs.py` + +- [ ] **Step 1: Implement scheduler/jobs.py** + +```python +import logging +from apscheduler.schedulers.background import BackgroundScheduler +from data.binance_rest import BinanceRestClient +from data.news_client import NewsClient +from data.social_client import SocialClient +from agents.technical import TechnicalAgent +from agents.news import NewsAgent +from agents.social import SocialAgent +from agents.ai_analyst import AIAgent +from engine.signal import SignalEngine +from engine.surge import SurgeDetector +from engine.portfolio import PortfolioManager +from data.db import Database + +logger = logging.getLogger(__name__) + +class AnalysisJob: + def __init__(self, binance: BinanceRestClient, news_client: NewsClient, + social_client: SocialClient, ai_agent: AIAgent, + signal_engine: SignalEngine, surge_detector: SurgeDetector, + portfolio: PortfolioManager, db: Database, + monitored_coins: list[str]): + self.binance = binance + self.news_client = news_client + self.social_client = social_client + self.ai_agent = ai_agent + self.tech_agent = TechnicalAgent() + self.news_agent = NewsAgent() + self.social_agent = SocialAgent() + self.signal_engine = signal_engine + self.surge_detector = surge_detector + self.portfolio = portfolio + self.db = db + self.monitored_coins = monitored_coins + self.latest_results: dict = {} + self.ai_summaries: dict[str, str] = {} # symbol -> AI summary text + + def run_analysis(self): + logger.info("Starting analysis cycle...") + try: + # 0. Surge detection — add surging coins to monitored list + try: + all_tickers = self.binance.client.get_ticker() + usdt_tickers = [t for t in all_tickers if t["symbol"].endswith("USDT")] + avg_volumes = {t["symbol"]: float(t["quoteVolume"]) * 0.7 for t in usdt_tickers} + surged = self.surge_detector.detect(usdt_tickers, avg_volumes) + for s in surged: + if s not in self.monitored_coins: + self.monitored_coins.append(s) + logger.info(f"Surge-added: {s}") + except Exception as e: + logger.warning(f"Surge detection failed: {e}") + + # 1. Fetch data + all_news = self.news_client.fetch_all() + all_social = self.social_client.fetch_reddit() + prices = self.binance.get_all_prices() + + coins_scores = {} + coins_for_ai = [] + + for symbol in self.monitored_coins: + try: + # Technical (multi-timeframe: 1h primary, 4h and 1d secondary) + df_1h = self.binance.get_ohlcv(symbol, interval="1h", limit=100) + score_1h = self.tech_agent.analyze(df_1h) + try: + df_4h = self.binance.get_ohlcv(symbol, interval="4h", limit=100) + score_4h = self.tech_agent.analyze(df_4h) + df_1d = self.binance.get_ohlcv(symbol, interval="1d", limit=200) + score_1d = self.tech_agent.analyze(df_1d) + tech_score = score_1h * 0.5 + score_4h * 0.3 + score_1d * 0.2 + except Exception: + tech_score = score_1h + + # News + coin_news = self.news_client.filter_by_coin(all_news, symbol) + news_score = self.news_agent.analyze(coin_news) + + # Social + coin_posts = self.social_client.filter_posts_by_coin(all_social, symbol) + sentiment = self.social_client.simple_sentiment(coin_posts) + social_score = self.social_agent.analyze(sentiment, mention_trend=1.0) + + coins_scores[symbol] = { + "technical": tech_score, + "news": news_score, + "social": social_score, + "ai": 50, # placeholder until AI runs + } + coins_for_ai.append({ + "symbol": symbol.replace("USDT", ""), + "price": prices.get(symbol, 0), + "change_pct": 0, + "technical_score": tech_score, + "news_score": news_score, + "social_score": social_score, + "headlines": [a["title"] for a in coin_news[:3]], + }) + except Exception as e: + logger.warning(f"Analysis failed for {symbol}: {e}") + coins_scores[symbol] = {"technical": 50, "news": 50, "social": 50, "ai": 50} + + # 2. AI batch analysis + if coins_for_ai: + ai_results = self.ai_agent.analyze_batch(coins_for_ai) + for symbol in coins_scores: + short = symbol.replace("USDT", "") + if short in ai_results: + coins_scores[symbol]["ai"] = ai_results[short].get("score", 50) + self.ai_summaries[symbol] = ai_results[short].get("summary", "") + + # 3. Compute signals + ranked = self.signal_engine.rank_coins(coins_scores) + self.latest_results = {r["symbol"]: r for r in ranked} + + # 4. Store signals + for r in ranked: + self.db.insert_signal( + r["symbol"], r["technical"], r["news"], r["social"], r["ai"], + r["composite"], r["signal"], + ) + + # 5. Portfolio actions + for r in ranked: + symbol = r["symbol"] + price = prices.get(symbol, 0) + if price <= 0: + continue + # Check exits first + self.portfolio.check_exit(symbol, price) + # Then entries + if r["signal"] == "BUY" and symbol not in self.portfolio.positions: + self.portfolio.buy(symbol, price, r["composite"]) + elif r["signal"] == "SELL" and symbol in self.portfolio.positions: + self.portfolio.sell(symbol, price, reason="signal-sell") + + # 6. Save portfolio snapshot + pv = self.portfolio.get_portfolio_value(prices) + self.db.insert_portfolio_snapshot( + pv["total_value"], pv["cash"], pv["total_pnl"], pv["pnl_pct"] + ) + + logger.info(f"Analysis complete. {len(ranked)} coins scored. Portfolio: ${pv['total_value']:.2f}") + + except Exception as e: + logger.error(f"Analysis cycle failed: {e}") + + +def create_scheduler(job: AnalysisJob, interval_minutes: int = 15) -> BackgroundScheduler: + scheduler = BackgroundScheduler() + scheduler.add_job(job.run_analysis, "interval", minutes=interval_minutes, id="analysis") + return scheduler +``` + +- [ ] **Step 2: Commit** + +```bash +git add scheduler/ +git commit -m "feat: APScheduler analysis job orchestrating all agents" +``` + +--- + +## Task 15: Streamlit Dashboard — Sidebar + +**Files:** +- Create: `dashboard/__init__.py` (empty) +- Create: `dashboard/sidebar.py` + +- [ ] **Step 1: Implement dashboard/sidebar.py** + +```python +import streamlit as st +import json +from config import DEFAULT_WEIGHTS + +def render_sidebar(latest_results: dict, db): + st.sidebar.title("Crypto Signals") + + # Navigation + page = st.sidebar.radio("View", ["Signals", "Portfolio"], label_visibility="collapsed") + + st.sidebar.divider() + + # Coin list sorted by score + if latest_results: + coins = sorted(latest_results.values(), key=lambda x: x["composite"], reverse=True) + + selected_coin = None + 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() + + # Weight sliders + 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) +``` + +- [ ] **Step 2: Commit** + +```bash +git add dashboard/ +git commit -m "feat: Streamlit sidebar with coin list and weight sliders" +``` + +--- + +## Task 16: Streamlit Dashboard — Detail Panels + +**Files:** +- Create: `dashboard/detail.py` + +- [ ] **Step 1: Implement dashboard/detail.py** + +```python +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") + color = {"BUY": "green", "HOLD": "orange", "SELL": "red"}.get(signal, "gray") + + 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}") + + # Tabs for detail panels + 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 + + # Add indicators + 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"), + ) + + # Candlestick + 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) + + # Bollinger Bands + 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) + + # RSI + 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) + + # MACD + 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) +``` + +- [ ] **Step 2: Commit** + +```bash +git add dashboard/detail.py +git commit -m "feat: detail panels with chart, news, social, AI views" +``` + +--- + +## Task 17: Streamlit Dashboard — Portfolio View + +**Files:** +- Create: `dashboard/portfolio_view.py` + +- [ ] **Step 1: Implement dashboard/portfolio_view.py** + +```python +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) + + # Summary bar + 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: + # Current holdings + 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: + # Allocation chart + 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") + + # Trade history + 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") +``` + +- [ ] **Step 2: Commit** + +```bash +git add dashboard/portfolio_view.py +git commit -m "feat: portfolio view with holdings, allocation, and trade history" +``` + +--- + +## Task 18: Main Streamlit App (run.py) + +**Files:** +- Create: `run.py` + +- [ ] **Step 1: Implement run.py** + +```python +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 + +# --- Logging --- +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__) + +# --- Page config --- +st.set_page_config(page_title="Crypto Signal Dashboard", layout="wide", page_icon="📊") + +# --- Init session state --- +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) + + # Load saved weights + saved_weights = db.load_setting("weights") + if saved_weights: + try: + signal_engine.set_weights(json.loads(saved_weights)) + except (json.JSONDecodeError, ValueError): + pass + + # Get monitored coins + 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") + + # Create analysis job + 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, + ) + + # Start scheduler + scheduler = create_scheduler(job, ANALYSIS_INTERVAL_MINUTES) + scheduler.start() + + # Start WebSocket + try: + binance_ws.start(monitored[:20]) # limit WS to top 20 + except Exception as e: + logger.warning(f"WebSocket start failed: {e}") + + # Run first analysis + 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, + } + + +# --- Main --- +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"] + + # Get current prices (prefer WS, fallback REST) + current_prices = binance_ws.get_all_prices() + if not current_prices: + try: + current_prices = binance_rest.get_all_prices() + except Exception: + current_prices = {} + + # Sidebar + 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() +``` + +- [ ] **Step 2: Commit** + +```bash +git add run.py +git commit -m "feat: main Streamlit app with dashboard orchestration" +``` + +--- + +## Task 19: Integration Test & First Run + +- [ ] **Step 1: Run all unit tests** + +Run: `cd crypto_news_trading && python -m pytest tests/ -v` +Expected: All tests pass + +- [ ] **Step 2: Create .env file with real API keys** + +Copy `.env.example` to `.env` and fill in real API keys. + +- [ ] **Step 3: Test launch** + +Run: `cd crypto_news_trading && streamlit run run.py` +Expected: Dashboard opens in browser at http://localhost:8501 + +- [ ] **Step 4: Verify each component** +- Sidebar shows coin list with scores +- Clicking a coin shows chart, news, social, AI panels +- Portfolio tab shows $200 initial capital +- Trades begin appearing after first analysis cycle + +- [ ] **Step 5: Final commit** + +```bash +git add -A +git commit -m "feat: complete crypto signal dashboard v1.0" +```