Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions dashboard/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
191 changes: 137 additions & 54 deletions quant_core.py
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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}")

Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
ccxt
pandas
pytz
PyYAML
plotly
streamlit