diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b94a6fd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +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 + pip install pytest + + - name: Compile sources + run: python -m py_compile $(git ls-files '*.py') + + - name: Run tests + run: python -m pytest -q diff --git a/README.md b/README.md index ded6b9c..0e7fa18 100644 --- a/README.md +++ b/README.md @@ -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** diff --git a/pages/02_SMA_Heatmap.py b/pages/02_SMA_Heatmap.py index 1378bd6..58e6e30 100644 --- a/pages/02_SMA_Heatmap.py +++ b/pages/02_SMA_Heatmap.py @@ -8,92 +8,6 @@ import pandas as pd import plotly.express as px import streamlit as st -import yfinance as yf - -st.set_page_config(page_title="SMA Heatmap", page_icon="📈", layout="wide") -st.title("📈 SMA Heatmap") - -@st.cache_data(show_spinner=False) -def load_prices(ticker: str, start: dt.date, end: dt.date) -> pd.DataFrame: - df = yf.download(ticker, start=start, end=end, auto_adjust=True, progress=False) - if df.empty: - return df - df = df[["Close"]].rename(columns={"Close": "close"}).dropna() - return df - -def fwd_return(close: pd.Series, horizon: int) -> pd.Series: - return close.shift(-horizon) / close - 1 - -def build_stats(df: pd.DataFrame, windows: list[int], horizon: int) -> pd.DataFrame: - out = [] - fr = fwd_return(df["close"], horizon) - for w in windows: - sma = df["close"].rolling(w, min_periods=w).mean() - above = fr[(df["close"] > sma)] - below = fr[(df["close"] <= sma)] - out.append({ - "window": w, - "ret_above": float(np.nanmean(above)), - "ret_below": float(np.nanmean(below)), - }) - return pd.DataFrame(out) - -# ------- UI ------- -with st.sidebar: - st.subheader("Parámetros") - ticker = st.text_input("Ticker", "AAPL").strip().upper() - col1, col2 = st.columns(2) - end = col2.date_input("Hasta", value=dt.date.today()) - start = col1.date_input("Desde", value=end - dt.timedelta(days=365*2)) - w_min, w_max = st.slider("SMA (min–max)", 5, 200, (10, 100)) - step = st.number_input("Paso", 1, 20, 5) - horizon = st.number_input("Horizonte (días)", 1, 60, 10) - -if start >= end: - st.warning("La fecha 'Desde' debe ser anterior a 'Hasta'.") - st.stop() - -windows = list(range(w_min, w_max + 1, step)) - -with st.spinner("Descargando precios…"): - prices = load_prices(ticker, start, end) -if prices.empty: - st.error("No hay datos para ese ticker/rango.") - st.stop() - -stats = build_stats(prices, windows, horizon) - -c1, c2 = st.columns(2) -with c1: - st.subheader(f"Retorno medio futuro {horizon}d cuando **precio > SMA**") - fig1 = px.imshow( - np.array([stats["ret_above"].values]), - aspect="auto", origin="lower", color_continuous_scale="RdBu", - labels=dict(color=f"Ret {horizon}d") - ) - fig1.update_xaxes(tickmode="array", - tickvals=list(range(len(windows))), - ticktext=[str(w) for w in windows], - title="SMA window") - fig1.update_yaxes(visible=False) - st.plotly_chart(fig1, use_container_width=True) - -with c2: - st.subheader(f"Retorno medio futuro {horizon}d cuando **precio ≤ SMA**") - fig2 = px.imshow( - np.array([stats["ret_below"].values]), - aspect="auto", origin="lower", color_continuous_scale="RdBu", - labels=dict(color=f"Ret {horizon}d") - ) - fig2.update_xaxes(tickmode="array", - tickvals=list(range(len(windows))), - ticktext=[str(w) for w in windows], - title="SMA window") - fig2.update_yaxes(visible=False) - st.plotly_chart(fig2, use_container_width=True) - -st.caption("Rojo = promedio positivo; Azul = promedio negativo. Muestra cómo se comportó el retorno futuro " - "según estar por encima o por debajo de cada SMA.") from quantboard.data import get_prices_cached from quantboard.optimize import grid_search_sma diff --git a/pages/03_Backtest.py b/pages/03_Backtest.py index 33c8fcc..f82b0d6 100644 --- a/pages/03_Backtest.py +++ b/pages/03_Backtest.py @@ -80,6 +80,31 @@ def _run_signals(close: pd.Series, fast: int, slow: int) -> pd.Series: return sig.replace(0, pd.NA).ffill().fillna(0.0) +def _clean_prices(df: pd.DataFrame) -> pd.DataFrame | None: + price_cols = ["open", "high", "low", "close", "volume"] + numeric = {col: pd.to_numeric(df.get(col), errors="coerce") for col in price_cols if col in df} + clean = df.assign(**numeric).dropna(subset=["close"]) + if clean.empty: + st.error("No price data available after cleaning.") + return None + return clean + + +def _run_signals(close: pd.Series, fast: int, slow: int) -> pd.Series: + if signals_sma_crossover is not None: + sig, _ = signals_sma_crossover(close, fast=fast, slow=slow) + return sig + + sma_fast = sma(close, fast) + sma_slow = sma(close, slow) + cross_up = (sma_fast > sma_slow) & (sma_fast.shift(1) <= sma_slow.shift(1)) + cross_dn = (sma_fast < sma_slow) & (sma_fast.shift(1) >= sma_slow.shift(1)) + sig = pd.Series(0.0, index=close.index) + sig = sig.where(~cross_up, 1.0) + sig = sig.where(~cross_dn, -1.0) + return sig.replace(0, pd.NA).ffill().fillna(0.0) + + def main() -> None: st.title("Backtest — SMA crossover") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2a855d9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/tests/test_backtest.py b/tests/test_backtest.py new file mode 100644 index 0000000..c53578b --- /dev/null +++ b/tests/test_backtest.py @@ -0,0 +1,36 @@ +import math + +import pandas as pd + +from quantboard.backtest import run_backtest + + +def test_backtest_produces_trades_and_metrics() -> None: + index = pd.date_range("2024-01-01", periods=10, freq="D") + close = pd.Series([100, 101, 102, 103, 102, 101, 100, 99, 100, 101], index=index, name="close") + + fast = close.rolling(2).mean() + slow = close.rolling(3).mean() + + signals = pd.Series(0.0, index=index) + crossover = fast > slow + signals[crossover.fillna(False)] = 1.0 + signals[~crossover.fillna(False)] = 0.0 + + bt, metrics = run_backtest(close.to_frame(), signals, interval="1d") + + assert set(metrics.keys()) == { + "CAGR", + "Sharpe", + "Sortino", + "Max Drawdown", + "Win rate", + "Avg trade return", + "Exposure (%)", + "Trades count", + } + + assert metrics["Trades count"] > 0 + assert math.isfinite(metrics["Avg trade return"]) + assert len(bt) == len(close) + assert bt["equity"].iloc[0] == 1.0 diff --git a/tests/test_backtest_alignment.py b/tests/test_backtest_alignment.py index f57c351..da6cca2 100644 --- a/tests/test_backtest_alignment.py +++ b/tests/test_backtest_alignment.py @@ -63,7 +63,7 @@ def test_flip_trade_keeps_flip_bar_with_prior_position(self) -> None: ] expected_old = [ (1.0 + rets.iloc[1]) - 1.0, - (1.0 + rets.iloc[2]) * (1.0 - rets.iloc[3]) - 1.0, + (1.0 + rets.iloc[2]) * (1.0 - rets.iloc[3]) * (1.0 - rets.iloc[4]) - 1.0, ] self.assertAlmostEqual(new_returns.iloc[0], expected_new[0]) diff --git a/tests/test_indicators.py b/tests/test_indicators.py new file mode 100644 index 0000000..60993cc --- /dev/null +++ b/tests/test_indicators.py @@ -0,0 +1,49 @@ +import math + +import pandas as pd +import pandas.testing as pdt + +from quantboard.indicators import rsi, sma + + +def test_sma_window_three_matches_expected_mean() -> None: + series = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0], name="close") + result = sma(series, window=3) + + expected = pd.Series([float("nan"), float("nan"), 2.0, 3.0, 4.0], name="SMA_3") + pdt.assert_series_equal(result, expected) + + +def test_rsi_matches_reference_value() -> None: + prices = pd.Series( + [ + 44.34, + 44.09, + 44.15, + 43.61, + 44.33, + 44.83, + 45.10, + 45.42, + 45.84, + 46.08, + 45.89, + 46.03, + 45.61, + 46.28, + 46.28, + 46.00, + 46.03, + 46.41, + 46.22, + 45.64, + 46.21, + ] + ) + + result = rsi(prices, period=14) + + assert math.isclose(result.iloc[13], 50.65741494172488, rel_tol=1e-12, abs_tol=1e-9) + assert math.isclose(result.iloc[-1], 50.40461501951188, rel_tol=1e-12, abs_tol=1e-9) + assert result.name == "RSI_14" + assert len(result) == len(prices)