Skip to content

Commit 04cfcf8

Browse files
committed
Persist sidebar parameters via shared state
1 parent 168d531 commit 04cfcf8

22 files changed

+1870
-134
lines changed

.dockerignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
__pycache__/
2+
*/__pycache__/*
3+
.venv/
4+
venv/
5+
.git/
6+
.github/
7+
.streamlit/cache/
8+
data/*

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
tests:
9+
runs-on: ubuntu-latest
10+
11+
steps:
12+
- name: Check out repository
13+
uses: actions/checkout@v3
14+
15+
- name: Set up Python
16+
uses: actions/setup-python@v4
17+
with:
18+
python-version: "3.11"
19+
20+
- name: Install dependencies
21+
run: |
22+
python -m pip install --upgrade pip
23+
pip install -r requirements.txt
24+
25+
- name: Compile sources
26+
run: python -m py_compile $(git ls-files '*.py')
27+
28+
- name: Run tests
29+
run: pytest -q

.streamlit/config.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,3 @@ primaryColor = "#F97316"
44
backgroundColor = "#0F1115"
55
secondaryBackgroundColor = "#1A1D23"
66
textColor = "#E5E7EB"
7-
font = "sans serif"

Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM python:3.11-slim
2+
3+
WORKDIR /app
4+
ENV PIP_NO_CACHE_DIR=1
5+
6+
COPY requirements.txt .
7+
RUN pip install --no-cache-dir -r requirements.txt
8+
9+
COPY . .
10+
11+
EXPOSE 8501
12+
13+
CMD ["streamlit", "run", "streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11

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

56
> **v0.2 – Novedades**
@@ -50,3 +51,12 @@ source .venv/bin/activate
5051
python -m pip install --upgrade pip
5152
pip install -r requirements.txt
5253
python -m streamlit run streamlit_app.py
54+
55+
### Ejecutar con Docker
56+
```bash
57+
# Construir la imagen
58+
docker build -t quantboard .
59+
60+
# Ejecutar el contenedor (Streamlit en http://localhost:8501)
61+
docker run -p 8501:8501 quantboard
62+
```

pages/01_Watchlist.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
import pandas as pd
44
import streamlit as st
55

6-
from quantboard.data import get_prices
6+
from quantboard.data import get_prices_cached
77
from quantboard.features.watchlist import load_watchlist, save_watchlist
8+
from quantboard.ui.state import set_param, shareable_link_button
89
from quantboard.ui.theme import apply_global_theme
910

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

1314
st.title("Watchlist")
15+
shareable_link_button()
1416

1517
watchlist = load_watchlist()
1618

@@ -37,7 +39,7 @@ def load_data(tickers: list[str]) -> pd.DataFrame:
3739
start = (datetime.today() - timedelta(days=30)).date()
3840
end = datetime.today().date()
3941
for tick in tickers:
40-
df = get_prices(tick, start=start, end=end, interval="1d")
42+
df = get_prices_cached(tick, start=start, end=end, interval="1d")
4143
if df.empty or "close" not in df.columns:
4244
continue
4345
close = pd.to_numeric(df["close"], errors="coerce").dropna()
@@ -60,7 +62,7 @@ def load_data(tickers: list[str]) -> pd.DataFrame:
6062
c2.write(f"{row['Last price']:.2f}")
6163
c3.write(f"{row['30d %']:.2f}%")
6264
if c4.button("Open in Home", key=f"open_{row['Ticker']}"):
63-
st.experimental_set_query_params(ticker=row["Ticker"])
65+
set_param("ticker", row["Ticker"])
6466
try:
6567
st.switch_page("streamlit_app.py")
6668
except Exception:

pages/02_SMA_Heatmap.py

Lines changed: 115 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,103 @@
1+
"""SMA heatmap optimisation page."""
2+
from __future__ import annotations
3+
14
from datetime import date, timedelta
25

36
import pandas as pd
47
import streamlit as st
58

6-
from quantboard.data import get_prices
9+
from quantboard.data import get_prices_cached
710
from quantboard.optimize import grid_search_sma
811
from quantboard.plots import heatmap_metric
12+
from quantboard.ui.state import get_param, set_param, shareable_link_button
913
from quantboard.ui.theme import apply_global_theme
1014

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

1418

1519
def _validate_prices(df: pd.DataFrame) -> pd.Series | None:
20+
"""Return a cleaned *close* series or ``None`` when empty."""
1621
if df.empty or "close" not in df.columns:
1722
st.error("No data for the selected range/interval.")
1823
return None
24+
1925
close = pd.to_numeric(df["close"], errors="coerce").dropna()
2026
if close.empty:
2127
st.error("No data for the selected range/interval.")
2228
return None
29+
2330
return close
2431

2532

33+
@st.cache_data(ttl=60)
34+
def _load_prices(ticker: str, start: date, end: date) -> pd.DataFrame:
35+
return get_prices_cached(ticker, start=start, end=end, interval="1d")
36+
37+
2638
def main() -> None:
2739
st.title("🔥 SMA Heatmap")
2840

41+
shareable_link_button()
42+
43+
today = date.today()
44+
default_start = today - timedelta(days=365 * 2)
45+
46+
ticker_default = str(get_param("ticker", "AAPL")).strip().upper() or "AAPL"
47+
end_default = get_param("heat_end", today)
48+
start_default = get_param("heat_start", default_start)
49+
fast_min_default = int(get_param("heat_fast_min", 10))
50+
fast_max_default = int(get_param("heat_fast_max", 25))
51+
slow_min_default = int(get_param("heat_slow_min", 50))
52+
slow_max_default = int(get_param("heat_slow_max", 120))
53+
54+
fast_min_default = max(5, min(60, fast_min_default))
55+
fast_max_default = max(fast_min_default, min(60, fast_max_default))
56+
slow_min_default = max(20, min(240, slow_min_default))
57+
slow_max_default = max(slow_min_default, min(240, slow_max_default))
58+
2959
with st.sidebar:
3060
st.header("Parameters")
31-
ticker = st.text_input("Ticker", value="AAPL").strip().upper()
32-
end = st.date_input("To", value=date.today())
33-
start = st.date_input("From", value=date.today() - timedelta(days=365 * 2))
34-
fast_min, fast_max = st.slider("Fast SMA range", 5, 60, (10, 25))
35-
slow_min, slow_max = st.slider("Slow SMA range", 20, 240, (50, 120))
36-
run_btn = st.button("Run search", type="primary")
61+
with st.form("heatmap_form"):
62+
ticker = st.text_input("Ticker", value=ticker_default).strip().upper()
63+
end = st.date_input("To", value=end_default)
64+
start = st.date_input("From", value=start_default)
65+
fast_min, fast_max = st.slider("Fast SMA range", 5, 60, (fast_min_default, fast_max_default))
66+
slow_min, slow_max = st.slider("Slow SMA range", 20, 240, (slow_min_default, slow_max_default))
67+
submitted = st.form_submit_button("Run search", type="primary")
68+
69+
if ticker != ticker_default:
70+
set_param("ticker", ticker or None)
71+
ticker_default = ticker or ticker_default
72+
73+
if end != end_default:
74+
set_param("heat_end", end)
75+
end_default = end
76+
77+
if start != start_default:
78+
set_param("heat_start", start)
79+
start_default = start
80+
81+
if int(fast_min) != int(fast_min_default) or int(fast_max) != int(fast_max_default):
82+
set_param("heat_fast_min", int(fast_min))
83+
set_param("heat_fast_max", int(fast_max))
84+
fast_min_default, fast_max_default = int(fast_min), int(fast_max)
85+
86+
if int(slow_min) != int(slow_min_default) or int(slow_max) != int(slow_max_default):
87+
set_param("heat_slow_min", int(slow_min))
88+
set_param("heat_slow_max", int(slow_max))
89+
slow_min_default, slow_max_default = int(slow_min), int(slow_max)
90+
91+
if not submitted:
92+
st.info("Choose parameters and click **Run search**.")
93+
return
3794

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

42-
if not run_btn:
43-
st.info("Choose parameters and click **Run search**.")
44-
return
45-
4699
with st.spinner("Fetching data..."):
47-
df = get_prices(ticker, start=start, end=end, interval="1d")
100+
df = _load_prices(ticker, start=start, end=end)
48101

49102
close = _validate_prices(df)
50103
if close is None:
@@ -57,28 +110,61 @@ def main() -> None:
57110
slow_range=range(int(slow_min), int(slow_max) + 1),
58111
metric="Sharpe",
59112
)
60-
# Invalidate combinations where fast >= slow
61-
for f in z.index:
62-
for s in z.columns:
63-
if int(f) >= int(s):
64-
z.loc[f, s] = float("nan")
113+
114+
# Invalidate combinations where fast >= slow
115+
for fast_window in z.index:
116+
for slow_window in z.columns:
117+
if int(fast_window) >= int(slow_window):
118+
z.loc[fast_window, slow_window] = float("nan")
65119

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

69-
# Best combination ignoring NaNs
70-
best = z.stack().dropna().astype(float).idxmax() if z.stack().dropna().size else None
71-
if best:
72-
f_best, s_best = map(int, best)
73-
st.success(f"Best combo: **Fast SMA {f_best} / Slow SMA {s_best}**")
74-
if st.button("Use in Home"):
75-
st.experimental_set_query_params(ticker=ticker)
76-
try:
77-
st.switch_page("streamlit_app.py")
78-
except Exception:
79-
st.info("Open Home from the menu; the ticker was set.")
80-
else:
123+
stacked = z.stack().dropna().astype(float)
124+
if stacked.empty:
81125
st.warning("No valid combination found in the selected range.")
126+
return
127+
128+
f_best, s_best = map(int, stacked.idxmax())
129+
st.success(f"Best combo: **Fast SMA {f_best} / Slow SMA {s_best}**")
130+
131+
top_df = (
132+
stacked.sort_values(ascending=False)
133+
.head(10)
134+
.rename_axis(("fast", "slow"))
135+
.reset_index(name="Sharpe")
136+
)
137+
138+
st.subheader(f"Top {len(top_df)} combinations")
139+
header_cols = st.columns([1, 1, 1, 1.5])
140+
header_cols[0].markdown("**Fast**")
141+
header_cols[1].markdown("**Slow**")
142+
header_cols[2].markdown("**Sharpe**")
143+
header_cols[3].markdown("**Action**")
144+
145+
for idx, row in top_df.iterrows():
146+
fast_val = int(row["fast"])
147+
slow_val = int(row["slow"])
148+
sharpe_val = float(row["Sharpe"])
149+
cols = st.columns([1, 1, 1, 1.5])
150+
cols[0].write(fast_val)
151+
cols[1].write(slow_val)
152+
cols[2].write(f"{sharpe_val:.2f}")
153+
if cols[3].button("Run Backtest", key=f"run_backtest_{idx}"):
154+
set_param("ticker", ticker or None)
155+
set_param("fast", int(fast_val))
156+
set_param("slow", int(slow_val))
157+
try:
158+
st.switch_page("pages/03_Backtest.py")
159+
except Exception: # pragma: no cover - depends on Streamlit runtime
160+
st.info("Open Backtest from the menu; the parameters were set.")
161+
162+
if st.button("Use in Home"):
163+
set_param("ticker", ticker or None)
164+
try:
165+
st.switch_page("streamlit_app.py")
166+
except Exception: # pragma: no cover - depends on Streamlit runtime
167+
st.info("Open Home from the menu; the ticker was set.")
82168

83169

84170
main()

0 commit comments

Comments
 (0)