Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 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
86 changes: 0 additions & 86 deletions pages/02_SMA_Heatmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions pages/03_Backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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))
36 changes: 36 additions & 0 deletions tests/test_backtest.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion tests/test_backtest_alignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
49 changes: 49 additions & 0 deletions tests/test_indicators.py
Original file line number Diff line number Diff line change
@@ -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)