diff --git a/dashboard/app.py b/dashboard/app.py index 037113b..44b8a9b 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -6,17 +6,29 @@ from plotly.subplots import make_subplots import streamlit as st -from quant_core import TZ, add_indicators, classify_signal, latest_snapshot +from quant_core import TZ, add_indicators, classify_signal, latest_snapshot, load_settings st.set_page_config(page_title="BTC Quant Dashboard", layout="wide") # ---------- Sidebar Controls ---------- st.sidebar.title("Controls") -timeframe = st.sidebar.selectbox("Timeframe", ["5m", "15m", "1h", "4h", "1d"], index=2) +SETTINGS = load_settings() +exchange_id = SETTINGS.get("exchange", "kraken") +symbol = SETTINGS.get("symbol", "BTC/USD") +available_timeframes = ["5m", "15m", "1h", "4h", "1d"] +default_timeframe = SETTINGS.get("timeframe", "1h") +try: + default_index = available_timeframes.index(default_timeframe) +except ValueError: + default_index = 2 + +timeframe = st.sidebar.selectbox("Timeframe", available_timeframes, index=default_index) refresh_s = st.sidebar.slider("Refresh (seconds)", 5, 120, 20, step=5) show_ema = st.sidebar.checkbox("Show EMA(50/200)", value=True) show_sma = st.sidebar.checkbox("Show SMA(50/200)", value=True) -st.sidebar.caption("Data source: Kraken via ccxt. Times in America/Denver.") +st.sidebar.caption( + f"Data source: {exchange_id.title()} via ccxt. Times in {TZ.zone}." +) # ---------- Header ---------- st.markdown( @@ -46,9 +58,15 @@ st.session_state["snapshot_timeframe"] = timeframe error_message = None +limit = SETTINGS.get("limit", 500) try: - snapshot = latest_snapshot(timeframe) + snapshot = latest_snapshot( + timeframe=timeframe, + limit=limit, + exchange_id=exchange_id, + symbol=symbol, + ) st.session_state["snapshot"] = snapshot st.session_state["snapshot_cached_at"] = datetime.now(TZ) except Exception as exc: # noqa: BLE001 - we want to show any failure to the user diff --git a/quant_core.py b/quant_core.py index 1c38b8d..e68701f 100644 --- a/quant_core.py +++ b/quant_core.py @@ -1,89 +1,158 @@ -# Shared core logic used by both Streamlit and Colab +"""Core quantitative utilities shared by the CLI and dashboard applications.""" + +from __future__ import annotations + import argparse +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Tuple + import ccxt import pandas as pd import pytz import yaml -from datetime import datetime -from pathlib import Path -TZ = pytz.timezone('America/Denver') +TZ = pytz.timezone("America/Denver") + +def load_settings(path: str | Path | None = None) -> Dict[str, Any]: + """Load YAML settings from ``conf/settings.yml``. + + Parameters + ---------- + path: + Optional override for the settings file location. + """ -def load_settings(path: str | None = None): - """Load YAML settings from conf/settings.yml.""" if path is None: path = Path(__file__).resolve().parent / "conf" / "settings.yml" + else: + path = Path(path) + with open(path, "r", encoding="utf-8") as fh: return yaml.safe_load(fh) -def _exchange(): - ex = ccxt.kraken({'enableRateLimit': True}) - ex.load_markets() - market = 'BTC/USD' if 'BTC/USD' in ex.symbols else 'XBT/USD' - return ex, market - - -def fetch_ohlcv(timeframe='1h', limit=500): - ex, market = _exchange() - ohlcv = ex.fetch_ohlcv(market, timeframe=timeframe, limit=limit) - df = pd.DataFrame(ohlcv, columns=['time', 'open', 'high', 'low', 'close', 'volume']) - df['time'] = pd.to_datetime(df['time'], unit='ms', utc=True).dt.tz_convert(TZ) - df.set_index('time', inplace=True) +def _get_exchange_and_symbol(exchange_id: str, symbol: str) -> Tuple[ccxt.Exchange, str]: + """Instantiate an exchange client and validate the requested symbol.""" + + try: + exchange_cls = getattr(ccxt, exchange_id.lower()) + except AttributeError as exc: # pragma: no cover - defensive guard + raise ValueError(f"Exchange '{exchange_id}' is not supported by ccxt.") from exc + + exchange = exchange_cls({"enableRateLimit": True}) + exchange.load_markets() + + if symbol not in exchange.symbols: + base, _, quote = symbol.partition("/") + # Allow common BTC base symbol aliases when a direct match is missing. + btc_aliases = { + "BTC": ["XBT"], + "XBT": ["BTC"], + } + for alt_base in btc_aliases.get(base.upper(), []): + candidate = f"{alt_base}/{quote}" if quote else alt_base + if candidate in exchange.symbols: + symbol = candidate + break + else: + raise ValueError( + f"Symbol '{symbol}' is not available on exchange '{exchange_id}'." + ) + + return exchange, symbol + + +def fetch_ohlcv( + timeframe: str = "1h", + limit: int = 500, + *, + exchange_id: str = "kraken", + symbol: str = "BTC/USD", +) -> pd.DataFrame: + """Fetch OHLCV data and return it as a timezone-aware dataframe.""" + + exchange, resolved_symbol = _get_exchange_and_symbol(exchange_id, symbol) + ohlcv = exchange.fetch_ohlcv(resolved_symbol, timeframe=timeframe, limit=limit) + df = pd.DataFrame(ohlcv, columns=["time", "open", "high", "low", "close", "volume"]) + df["time"] = pd.to_datetime(df["time"], unit="ms", utc=True).dt.tz_convert(TZ) + df.set_index("time", inplace=True) return df -def add_indicators(df): +def add_indicators(df: pd.DataFrame) -> pd.DataFrame: + """Append common trend and momentum indicators to *df*.""" + df = df.copy() # SMAs - df['SMA50'] = df['close'].rolling(50).mean() - df['SMA200'] = df['close'].rolling(200).mean() + df["SMA50"] = df["close"].rolling(50).mean() + df["SMA200"] = df["close"].rolling(200).mean() # EMAs - df['EMA50'] = df['close'].ewm(span=50, adjust=False).mean() - df['EMA200'] = df['close'].ewm(span=200, adjust=False).mean() - - # RSI (14) - delta = df['close'].diff() - gain = delta.clip(lower=0).rolling(14).mean() - loss = (-delta.clip(upper=0)).rolling(14).mean() - rs = gain / loss - df['RSI'] = 100 - (100 / (1 + rs)) + df["EMA50"] = df["close"].ewm(span=50, adjust=False).mean() + df["EMA200"] = df["close"].ewm(span=200, adjust=False).mean() + + # RSI (Wilder's smoothing, period 14) + delta = df["close"].diff() + gain = delta.clip(lower=0) + loss = -delta.clip(upper=0) + avg_gain = gain.ewm(alpha=1 / 14, min_periods=14, adjust=False).mean() + avg_loss = loss.ewm(alpha=1 / 14, min_periods=14, adjust=False).mean() + rs = avg_gain / avg_loss + rsi = 100 - (100 / (1 + rs)) + rsi = rsi.where(avg_loss != 0, 100) + rsi = rsi.where(avg_gain != 0, 0) + df["RSI"] = rsi # MACD (12,26,9) - ema12 = df['close'].ewm(span=12, adjust=False).mean() - ema26 = df['close'].ewm(span=26, adjust=False).mean() - df['MACD'] = ema12 - ema26 - df['MACDSignal'] = df['MACD'].ewm(span=9, adjust=False).mean() - df['MACDHist'] = df['MACD'] - df['MACDSignal'] + ema12 = df["close"].ewm(span=12, adjust=False).mean() + ema26 = df["close"].ewm(span=26, adjust=False).mean() + df["MACD"] = ema12 - ema26 + df["MACDSignal"] = df["MACD"].ewm(span=9, adjust=False).mean() + df["MACDHist"] = df["MACD"] - df["MACDSignal"] return df -def classify_signal(row): - sma_bull = row['SMA50'] > row['SMA200'] - sma_bear = row['SMA50'] < row['SMA200'] - ema_bull = row['EMA50'] > row['EMA200'] - ema_bear = row['EMA50'] < row['EMA200'] - rsi_bull = row['RSI'] >= 50 - rsi_bear = row['RSI'] <= 50 - macd_bull = row['MACD'] > row['MACDSignal'] - macd_bear = row['MACD'] < row['MACDSignal'] +def classify_signal(row: pd.Series) -> str: + """Return a coarse market regime classification for the latest row.""" + + sma_bull = row["SMA50"] > row["SMA200"] + sma_bear = row["SMA50"] < row["SMA200"] + ema_bull = row["EMA50"] > row["EMA200"] + ema_bear = row["EMA50"] < row["EMA200"] + rsi_bull = row["RSI"] > 50 + rsi_bear = row["RSI"] < 50 + macd_bull = row["MACD"] > row["MACDSignal"] + macd_bear = row["MACD"] < row["MACDSignal"] bull_votes = sum([sma_bull, ema_bull, rsi_bull, macd_bull]) bear_votes = sum([sma_bear, ema_bear, rsi_bear, macd_bear]) if bull_votes >= 3 and bull_votes > bear_votes: - return 'Bullish' + return "Bullish" if bear_votes >= 3 and bear_votes > bull_votes: - return 'Bearish' - return 'Neutral' - - -def latest_snapshot(timeframe='1h', limit=500): - df = add_indicators(fetch_ohlcv(timeframe, limit=limit)) + return "Bearish" + return "Neutral" + + +def latest_snapshot( + timeframe: str = "1h", + limit: int = 500, + *, + exchange_id: str = "kraken", + symbol: str = "BTC/USD", +) -> tuple[pd.DataFrame, str, str]: + df = add_indicators( + fetch_ohlcv( + timeframe=timeframe, + limit=limit, + exchange_id=exchange_id, + symbol=symbol, + ) + ) last = df.iloc[-1] sig = classify_signal(last) now = datetime.now(TZ).strftime('%Y-%m-%d %H:%M:%S') @@ -110,6 +179,8 @@ def main(): return settings = load_settings() + exchange_id = settings.get("exchange", "kraken") + symbol = settings.get("symbol", "BTC/USD") if args.report is not None: base = args.report or settings.get("logging", {}).get("dir", "logs") @@ -123,7 +194,14 @@ def main(): limit = settings.get("limit", 500) if args.live: - df = add_indicators(fetch_ohlcv(timeframe=timeframe, limit=limit)) + df = add_indicators( + fetch_ohlcv( + timeframe=timeframe, + limit=limit, + exchange_id=exchange_id, + symbol=symbol, + ) + ) sig = classify_signal(df.iloc[-1]) print(sig) return @@ -133,7 +211,12 @@ def main(): return # Default behavior - df, sig, now = latest_snapshot(timeframe=timeframe, limit=limit) + df, sig, now = latest_snapshot( + timeframe=timeframe, + limit=limit, + exchange_id=exchange_id, + symbol=symbol, + ) print(df.tail()) print(f"Signal: {sig} at {now}") diff --git a/requirements.txt b/requirements.txt index 2099293..af993dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ ccxt pandas pytz +PyYAML plotly streamlit