Skip to content
Closed
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
16 changes: 16 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.git
.venv
venv
__pycache__
*.pyc
*.pyo
*.pyd
*.db
*.sqlite
*.log
.env
.DS_Store
.pytest_cache
.mypy_cache
.streamlit/cache
.streamlit/state
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: CI

on:
push:
pull_request:

jobs:
tests:
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v3

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt

- name: Compile sources
run: python -m py_compile $(git ls-files '*.py')

- name: Run tests
run: pytest -q
13 changes: 13 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM python:3.11-slim

WORKDIR /app
ENV PIP_NO_CACHE_DIR=1

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8501

CMD ["streamlit", "run", "streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@

# QuantBoard — Análisis técnico y Backtesting
[![CI](https://github.com/felipeimpieri/quantboard/actions/workflows/ci.yml/badge.svg)](https://github.com/felipeimpieri/quantboard/actions/workflows/ci.yml)
Dashboard interactivo hecho con **Streamlit + yfinance + Plotly** para analizar precios, aplicar indicadores y correr backtests simples.

> **v0.2 – Novedades**
Expand Down Expand Up @@ -50,3 +51,12 @@ source .venv/bin/activate
python -m pip install --upgrade pip
pip install -r requirements.txt
python -m streamlit run streamlit_app.py

### Ejecutar con Docker
```bash
# Construir la imagen
docker build -t quantboard:latest .

# Ejecutar el contenedor (Streamlit en http://localhost:8501)
docker run --rm -p 8501:8501 quantboard:latest
```
8 changes: 5 additions & 3 deletions pages/01_Watchlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
import pandas as pd
import streamlit as st

from quantboard.data import get_prices
from quantboard.data import get_prices_cached
from quantboard.features.watchlist import load_watchlist, save_watchlist
from quantboard.ui.state import set_param, shareable_link_button
from quantboard.ui.theme import apply_global_theme

st.set_page_config(page_title="Watchlist", page_icon="👀", layout="wide")
apply_global_theme()

st.title("Watchlist")
shareable_link_button()

watchlist = load_watchlist()

Expand All @@ -37,7 +39,7 @@ def load_data(tickers: list[str]) -> pd.DataFrame:
start = (datetime.today() - timedelta(days=30)).date()
end = datetime.today().date()
for tick in tickers:
df = get_prices(tick, start=start, end=end, interval="1d")
df = get_prices_cached(tick, start=start, end=end, interval="1d")
if df.empty or "close" not in df.columns:
continue
close = pd.to_numeric(df["close"], errors="coerce").dropna()
Expand All @@ -60,7 +62,7 @@ def load_data(tickers: list[str]) -> pd.DataFrame:
c2.write(f"{row['Last price']:.2f}")
c3.write(f"{row['30d %']:.2f}%")
if c4.button("Open in Home", key=f"open_{row['Ticker']}"):
st.experimental_set_query_params(ticker=row["Ticker"])
set_param("ticker", row["Ticker"])
try:
st.switch_page("streamlit_app.py")
except Exception:
Expand Down
130 changes: 101 additions & 29 deletions pages/02_SMA_Heatmap.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,89 @@
"""SMA heatmap optimisation page."""
from __future__ import annotations

from datetime import date, timedelta

import pandas as pd
import streamlit as st

from quantboard.data import get_prices
from quantboard.data import get_prices_cached
from quantboard.optimize import grid_search_sma
from quantboard.plots import heatmap_metric
from quantboard.ui.state import get_param, set_param, shareable_link_button
from quantboard.ui.theme import apply_global_theme

st.set_page_config(page_title="SMA Heatmap", page_icon="🔥", layout="wide")
apply_global_theme()


def _validate_prices(df: pd.DataFrame) -> pd.Series | None:
"""Return a cleaned *close* series or ``None`` when empty."""
if df.empty or "close" not in df.columns:
st.error("No data for the selected range/interval.")
return None

close = pd.to_numeric(df["close"], errors="coerce").dropna()
if close.empty:
st.error("No data for the selected range/interval.")
return None

return close


@st.cache_data(ttl=60)
def _load_prices(ticker: str, start: date, end: date) -> pd.DataFrame:
return get_prices_cached(ticker, start=start, end=end, interval="1d")


def main() -> None:
st.title("🔥 SMA Heatmap")

shareable_link_button()

today = date.today()
default_start = today - timedelta(days=365 * 2)

ticker_default = str(get_param("ticker", "AAPL")).strip().upper() or "AAPL"
end_default = get_param("heat_end", today)
start_default = get_param("heat_start", default_start)
fast_min_default = int(get_param("heat_fast_min", 10))
fast_max_default = int(get_param("heat_fast_max", 25))
slow_min_default = int(get_param("heat_slow_min", 50))
slow_max_default = int(get_param("heat_slow_max", 120))

fast_min_default = max(5, min(60, fast_min_default))
fast_max_default = max(fast_min_default, min(60, fast_max_default))
slow_min_default = max(20, min(240, slow_min_default))
slow_max_default = max(slow_min_default, min(240, slow_max_default))

with st.sidebar:
st.header("Parameters")
ticker = st.text_input("Ticker", value="AAPL").strip().upper()
end = st.date_input("To", value=date.today())
start = st.date_input("From", value=date.today() - timedelta(days=365 * 2))
fast_min, fast_max = st.slider("Fast SMA range", 5, 60, (10, 25))
slow_min, slow_max = st.slider("Slow SMA range", 20, 240, (50, 120))
run_btn = st.button("Run search", type="primary")
with st.form("heatmap_form"):
ticker = st.text_input("Ticker", value=ticker_default).strip().upper()
end = st.date_input("To", value=end_default)
start = st.date_input("From", value=start_default)
fast_min, fast_max = st.slider("Fast SMA range", 5, 60, (fast_min_default, fast_max_default))
slow_min, slow_max = st.slider("Slow SMA range", 20, 240, (slow_min_default, slow_max_default))
submitted = st.form_submit_button("Run search", type="primary")

set_param("ticker", ticker or None)
set_param("heat_end", end)
set_param("heat_start", start)
set_param("heat_fast_min", int(fast_min))
set_param("heat_fast_max", int(fast_max))
set_param("heat_slow_min", int(slow_min))
set_param("heat_slow_max", int(slow_max))

if not submitted:
st.info("Choose parameters and click **Run search**.")
return

if fast_min >= slow_min:
st.error("Fast SMA range must stay below the Slow SMA range.")
return

if not run_btn:
st.info("Choose parameters and click **Run search**.")
return

with st.spinner("Fetching data..."):
df = get_prices(ticker, start=start, end=end, interval="1d")
df = _load_prices(ticker, start=start, end=end)

close = _validate_prices(df)
if close is None:
Expand All @@ -57,28 +96,61 @@ def main() -> None:
slow_range=range(int(slow_min), int(slow_max) + 1),
metric="Sharpe",
)
# Invalidate combinations where fast >= slow
for f in z.index:
for s in z.columns:
if int(f) >= int(s):
z.loc[f, s] = float("nan")

# Invalidate combinations where fast >= slow
for fast_window in z.index:
for slow_window in z.columns:
if int(fast_window) >= int(slow_window):
z.loc[fast_window, slow_window] = float("nan")

st.subheader("Heatmap (Sharpe)")
st.plotly_chart(heatmap_metric(z, title="SMA grid — Sharpe"), use_container_width=True)

# Best combination ignoring NaNs
best = z.stack().dropna().astype(float).idxmax() if z.stack().dropna().size else None
if best:
f_best, s_best = map(int, best)
st.success(f"Best combo: **Fast SMA {f_best} / Slow SMA {s_best}**")
if st.button("Use in Home"):
st.experimental_set_query_params(ticker=ticker)
try:
st.switch_page("streamlit_app.py")
except Exception:
st.info("Open Home from the menu; the ticker was set.")
else:
stacked = z.stack().dropna().astype(float)
if stacked.empty:
st.warning("No valid combination found in the selected range.")
return

f_best, s_best = map(int, stacked.idxmax())
st.success(f"Best combo: **Fast SMA {f_best} / Slow SMA {s_best}**")

top_df = (
stacked.sort_values(ascending=False)
.head(10)
.rename_axis(("fast", "slow"))
.reset_index(name="Sharpe")
)

st.subheader(f"Top {len(top_df)} combinations")
header_cols = st.columns([1, 1, 1, 1.5])
header_cols[0].markdown("**Fast**")
header_cols[1].markdown("**Slow**")
header_cols[2].markdown("**Sharpe**")
header_cols[3].markdown("**Action**")

for idx, row in top_df.iterrows():
fast_val = int(row["fast"])
slow_val = int(row["slow"])
sharpe_val = float(row["Sharpe"])
cols = st.columns([1, 1, 1, 1.5])
cols[0].write(fast_val)
cols[1].write(slow_val)
cols[2].write(f"{sharpe_val:.2f}")
if cols[3].button("Run Backtest", key=f"run_backtest_{idx}"):
set_param("ticker", ticker or None)
set_param("fast", int(fast_val))
set_param("slow", int(slow_val))
try:
st.switch_page("pages/03_Backtest.py")
except Exception: # pragma: no cover - depends on Streamlit runtime
st.info("Open Backtest from the menu; the parameters were set.")

if st.button("Use in Home"):
set_param("ticker", ticker or None)
try:
st.switch_page("streamlit_app.py")
except Exception: # pragma: no cover - depends on Streamlit runtime
st.info("Open Home from the menu; the ticker was set.")


main()
Loading
Loading