Files
2026-03-31 09:57:47 +02:00

561 lines
18 KiB
Python

"""
Event-Driven Backtesting Engine
================================
Single Streamlit entry point — controls everything:
- Data download (yfinance -> CSV)
- C++ build (cmake)
- Engine selection (Python / C++)
- Backtest execution + visualization
"""
from __future__ import annotations
import os, sys
import subprocess
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
import yfinance as yf
import yaml
# ── Page config ───────────────────────────────────────────────────────────────
st.set_page_config(
page_title="Event-Driven Backtester",
page_icon="📈",
layout="wide",
initial_sidebar_state="expanded",
)
st.markdown(
"""
<style>
.stApp { background-color: #0E1117; }
section[data-testid="stSidebar"] { background-color: #161B22; }
div[data-testid="metric-container"] {
background: #161B22; border: 1px solid #30363D;
border-radius: 8px; padding: 14px 18px;
}
div[data-testid="metric-container"] label { color: #8B949E !important; font-size: 0.78rem; }
div[data-testid="metric-container"] div[data-testid="stMetricValue"] {
color: #E6EDF3 !important; font-size: 1.4rem; font-weight: 600;
}
h1, h2, h3 { color: #E6EDF3 !important; }
button[data-baseweb="tab"] { color: #8B949E; }
button[data-baseweb="tab"][aria-selected="true"] { color: #4C9BE8 !important; }
div.stButton > button {
background: #4C9BE8; color: white; border: none;
border-radius: 6px; padding: 0.5rem 1.4rem;
font-weight: 600; width: 100%;
}
div.stButton > button:hover { background: #3A82D0; }
div.stButton > button:disabled { background: #2a2a3a; color: #555; }
</style>
""",
unsafe_allow_html=True,
)
BG = "#0E1117"
PANEL = "#161B22"
BLUE = "#4C9BE8"
RED = "#E84C4C"
GRAY = "#8B949E"
GRID = "#21262D"
GREEN = "#2ECC71"
CORE_BINARY = os.path.normpath(os.path.join("build", "backtest"))
_WORK_DIR = os.path.join(
os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")),
"backtest"
)
_CONFIG_DIR = os.path.join(
os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
"backtest"
)
CONFIG_FILE = os.path.join(_CONFIG_DIR, "config.yml")
def load_config():
os.makedirs(_CONFIG_DIR, exist_ok=True)
os.makedirs(os.path.join(_WORK_DIR, "data"), exist_ok=True)
os.makedirs(os.path.join(_WORK_DIR, "results"), exist_ok=True)
if not os.path.exists(CONFIG_FILE):
cfg = {
"data_dir": os.path.join(_WORK_DIR, "data"),
"results_dir": os.path.join(_WORK_DIR, "results"),
}
with open(CONFIG_FILE, "w") as f:
yaml.dump(cfg, f)
with open(CONFIG_FILE) as f:
return yaml.safe_load(f)
config = load_config()
DATA_DIR = config["data_dir"]
RESULTS_DIR = config["results_dir"]
print(f"DEBUG CORE_BINARY={CORE_BINARY}", file=sys.stderr)
print(f"DEBUG DATA_DIR={DATA_DIR}", file=sys.stderr)
print(f"DEBUG RESULTS_DIR={RESULTS_DIR}", file=sys.stderr)
print(f"DEBUG CONFIG_FILE={CONFIG_FILE}", file=sys.stderr)
def _base_layout(**kw) -> dict:
return dict(
plot_bgcolor=BG,
paper_bgcolor=BG,
font=dict(color=GRAY, size=12),
xaxis=dict(showgrid=True, gridcolor=GRID, zeroline=False),
yaxis=dict(showgrid=True, gridcolor=GRID, zeroline=False),
legend=dict(bgcolor=PANEL, bordercolor=GRID, borderwidth=1),
margin=dict(l=50, r=20, t=40, b=40),
**kw,
)
def fmt_pct(v: float) -> str:
return f"{v:.2%}"
def fmt_f(v: float) -> str:
return f"{v:.2f}"
def data_exists(symbols: list[str]) -> bool:
return all(os.path.isfile(f"{DATA_DIR}/{s}.csv") for s in symbols)
def binary_exists() -> bool:
return os.path.isfile(CORE_BINARY)
def load_benchmark(ticker: str, start: str, end: str, ref: float) -> pd.Series | None:
try:
raw = yf.download(
ticker, start=start, end=end, auto_adjust=True, progress=False
)
b = raw["Close"].squeeze()
return b / b.iloc[0] * ref
except Exception:
return None
def compute_performance(equity: pd.Series, initial_capital: float) -> dict:
returns = equity.pct_change().dropna()
total_ret = (equity.iloc[-1] / initial_capital) - 1
years = (equity.index[-1] - equity.index[0]).days / 365.25
cagr = (equity.iloc[-1] / initial_capital) ** (1 / years) - 1
sharpe = returns.mean() / returns.std() * np.sqrt(252) if returns.std() > 0 else 0
rolling_max = equity.cummax()
drawdown = (equity - rolling_max) / rolling_max
max_dd = drawdown.min()
vol = returns.std() * np.sqrt(252)
calmar = cagr / abs(max_dd) if max_dd != 0 else 0
return dict(
total_return=total_ret,
cagr=cagr,
sharpe=sharpe,
max_dd=max_dd,
volatility=vol,
calmar=calmar,
drawdown=drawdown,
returns=returns,
)
def plot_equity(
equity: pd.Series, bench: pd.Series | None, trades: pd.DataFrame
) -> go.Figure:
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=equity.index, y=equity, name="Strategy", line=dict(color=BLUE, width=2)
)
)
if bench is not None:
b = bench.reindex(equity.index, method="ffill")
fig.add_trace(
go.Scatter(
x=b.index,
y=b,
name="Benchmark",
line=dict(color=GRAY, width=1.5, dash="dot"),
)
)
if not trades.empty:
for sig, color, sym in [
("BUY", GREEN, "triangle-up"),
("SELL", RED, "triangle-down"),
]:
sub = trades[trades["signal"] == sig]
if sub.empty:
continue
yvals = [equity.asof(d) for d in pd.to_datetime(sub["date"])]
fig.add_trace(
go.Scatter(
x=sub["date"],
y=yvals,
mode="markers",
name=sig,
marker=dict(
symbol=sym,
size=10,
color=color,
line=dict(color="white", width=1),
),
)
)
fig.update_layout(title="Equity Curve vs Benchmark", **_base_layout())
return fig
def plot_drawdown(drawdown: pd.Series) -> go.Figure:
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=drawdown.index,
y=drawdown * 100,
fill="tozeroy",
fillcolor="rgba(232,76,76,0.25)",
line=dict(color=RED, width=1.5),
name="Drawdown (%)",
)
)
fig.update_layout(title="Drawdown", yaxis_ticksuffix="%", **_base_layout())
return fig
def plot_rolling_sharpe(returns: pd.Series, window: int = 63) -> go.Figure:
rs = returns.rolling(window).mean() / returns.rolling(window).std() * np.sqrt(252)
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=rs.index,
y=rs,
line=dict(color=BLUE, width=1.5),
name=f"Rolling Sharpe ({window}d)",
)
)
fig.add_hline(y=0, line_color=GRAY, line_dash="dot")
fig.update_layout(title=f"Rolling Sharpe ({window}-day)", **_base_layout())
return fig
def plot_returns_dist(returns: pd.Series) -> go.Figure:
fig = go.Figure()
fig.add_trace(
go.Histogram(
x=returns * 100,
nbinsx=60,
marker_color=BLUE,
opacity=0.8,
name="Daily returns",
)
)
fig.add_vline(x=0, line_color=GRAY, line_dash="dot")
fig.update_layout(
title="Daily Returns Distribution",
xaxis_title="Return (%)",
yaxis_title="Count",
**_base_layout(),
)
return fig
def render_results(
equity: pd.Series,
perf: dict,
trades: pd.DataFrame,
bench_ticker: str,
initial_capital: float,
):
bench = load_benchmark(
bench_ticker,
equity.index[0].strftime("%Y-%m-%d"),
equity.index[-1].strftime("%Y-%m-%d"),
equity.iloc[0],
)
bench_perf = (
compute_performance(bench.reindex(equity.index, method="ffill"), equity.iloc[0])
if bench is not None
else None
)
st.markdown("### Performance Summary")
cols = st.columns(6)
kpis = [
(
"Total Return",
fmt_pct(perf["total_return"]),
(
fmt_pct(perf["total_return"] - bench_perf["total_return"])
if bench_perf
else None
),
),
(
"CAGR",
fmt_pct(perf["cagr"]),
fmt_pct(perf["cagr"] - bench_perf["cagr"]) if bench_perf else None,
),
(
"Sharpe Ratio",
fmt_f(perf["sharpe"]),
fmt_f(perf["sharpe"] - bench_perf["sharpe"]) if bench_perf else None,
),
(
"Max Drawdown",
fmt_pct(perf["max_dd"]),
fmt_pct(perf["max_dd"] - bench_perf["max_dd"]) if bench_perf else None,
),
(
"Volatility",
fmt_pct(perf["volatility"]),
(
fmt_pct(perf["volatility"] - bench_perf["volatility"])
if bench_perf
else None
),
),
(
"Calmar Ratio",
fmt_f(perf["calmar"]),
fmt_f(perf["calmar"] - bench_perf["calmar"]) if bench_perf else None,
),
]
for col, (label, val, delta) in zip(cols, kpis):
col.metric(label, val, delta)
st.markdown("---")
tab1, tab2, tab3, tab4 = st.tabs(
["📈 Equity Curve", "📉 Drawdown", "📊 Returns", "📋 Trades"]
)
with tab1:
st.plotly_chart(plot_equity(equity, bench, trades), width="stretch")
with tab2:
c1, c2 = st.columns([3, 2])
with c1:
st.plotly_chart(plot_drawdown(perf["drawdown"]), width="stretch")
with c2:
st.plotly_chart(plot_rolling_sharpe(perf["returns"]), width="stretch")
with tab3:
st.plotly_chart(plot_returns_dist(perf["returns"]), width="stretch")
with tab4:
if not trades.empty:
df = trades.copy()
df["date"] = pd.to_datetime(df["date"]).dt.strftime("%Y-%m-%d")
df.columns = [c.capitalize() for c in df.columns]
st.dataframe(
df.sort_values("Date", ascending=False),
width="stretch",
hide_index=True,
)
buys = (df["Signal"] == "BUY").sum()
sells = (df["Signal"] == "SELL").sum()
st.caption(f"Total trades: {len(df)} - BUY: {buys} / SELL: {sells}")
else:
st.info("No trades generated.")
# ── Sidebar ───────────────────────────────────────────────────────────────────
with st.sidebar:
st.markdown("## 📈 Backtester")
st.markdown("---")
st.markdown("#### 1. Data")
raw_symbols = st.text_input("Symbols", value="AAPL, MSFT")
symbols = [s.strip().upper() for s in raw_symbols.split(",") if s.strip()]
col1, col2 = st.columns(2)
with col1:
start = st.date_input("Start", value=pd.Timestamp("2015-01-01"))
with col2:
end = st.date_input("End", value=pd.Timestamp("2024-01-01"))
if data_exists(symbols):
st.success(f"Data ready: {', '.join(symbols)}")
else:
missing = [s for s in symbols if not os.path.isfile(f"{DATA_DIR}/{s}.csv")]
st.warning(f"Missing: {', '.join(missing)}")
download_btn = st.button("⬇ Download data")
st.markdown("---")
st.markdown("#### 2. Engine")
engine = st.radio(
"Engine", ["Python (strategies)", "C++ (core)"], label_visibility="collapsed"
)
use_cpp = engine.startswith("C++")
if use_cpp:
if binary_exists():
st.success("Binary ready: `./build/backtest`")
else:
st.warning("Binary not built yet.")
build_btn = st.button("🔨 Build C++ core")
else:
build_btn = False
st.markdown("---")
st.markdown("#### 3. Strategy")
fast_window = st.slider("Fast window", 5, 100, 20, step=5)
slow_window = st.slider("Slow window", 20, 300, 50, step=10)
if fast_window >= slow_window:
st.warning("Fast must be < slow.")
initial_capital = st.number_input(
"Capital ($)",
min_value=10_000,
max_value=10_000_000,
value=100_000,
step=10_000,
)
benchmark = st.text_input("Benchmark", value="SPY").strip().upper()
st.markdown("---")
can_run = (
data_exists(symbols)
and fast_window < slow_window
and (not use_cpp or binary_exists())
)
run_btn = st.button("▶ Run backtest", disabled=not can_run)
# ── Main area ─────────────────────────────────────────────────────────────────
st.markdown("# 📈 Event-Driven Backtesting Engine")
st.markdown(
f"**Engine:** {'C++ core' if use_cpp else 'Python strategies'} - "
f"**SMA** ({fast_window}/{slow_window}) - "
f"**Symbols:** {', '.join(symbols)} - "
f"**Capital:** ${initial_capital:,.0f}"
)
st.markdown("---")
# ── Download ──────────────────────────────────────────────────────────────────
if download_btn:
cmd = (
[sys.executable, "scripts/download_data.py"]
+ symbols
+ ["--start", str(start), "--end", str(end), "--outdir", DATA_DIR]
)
with st.status("Downloading data...", expanded=True) as status:
proc = subprocess.run(cmd, capture_output=True, text=True)
st.code(proc.stdout + proc.stderr, language="bash")
status.update(
label="Download complete." if proc.returncode == 0 else "Download failed.",
state="complete" if proc.returncode == 0 else "error",
)
# ── Build ─────────────────────────────────────────────────────────────────────
if build_btn:
with st.status("Building C++ core...", expanded=True) as status:
cfg = subprocess.run(
["cmake", "-B", "build", "-DCMAKE_BUILD_TYPE=Release"],
capture_output=True,
text=True,
)
bld = subprocess.run(
["cmake", "--build", "build", "-j4"], capture_output=True, text=True
)
st.code(cfg.stdout + cfg.stderr + bld.stdout + bld.stderr, language="bash")
ok = cfg.returncode == 0 and bld.returncode == 0
status.update(
label="Build complete." if ok else "Build failed.",
state="complete" if ok else "error",
)
# ── Idle ──────────────────────────────────────────────────────────────────────
if not run_btn:
steps = []
if not data_exists(symbols):
steps.append("- Click **⬇ Download data**")
if use_cpp and not binary_exists():
steps.append("- Click **🔨 Build C++ core**")
if fast_window >= slow_window:
steps.append("- Fix fast/slow windows")
st.info(
"Ready - click **▶ Run backtest**."
if not steps
else "Before running:\n" + "\n".join(steps)
)
st.stop()
# ── Python engine ─────────────────────────────────────────────────────────────
if not use_cpp:
try:
from strategies import run_backtest
equity, perf, trade_log = run_backtest(
symbols, str(start), str(end), initial_capital, fast_window, slow_window
)
trades = pd.DataFrame(trade_log) if trade_log else pd.DataFrame()
except Exception as e:
st.error(f"Python engine error: {e}")
st.stop()
render_results(equity, perf, trades, benchmark, initial_capital)
# ── C++ engine ────────────────────────────────────────────────────────────────
else:
cmd = [
CORE_BINARY,
DATA_DIR,
",".join(symbols),
str(fast_window),
str(slow_window),
str(int(initial_capital)),
]
with st.status("Running C++ core...", expanded=False) as status:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
status.update(
label="C++ core finished." if proc.returncode == 0 else "C++ core failed.",
state="complete" if proc.returncode == 0 else "error",
)
if proc.returncode != 0:
st.code(proc.stderr, language="bash")
st.stop()
with st.expander("C++ engine output", expanded=False):
st.code(proc.stdout, language="bash")
equity_path = os.path.join(RESULTS_DIR, "equity.csv")
trades_path = os.path.join(RESULTS_DIR, "trades.csv")
perf_path = os.path.join(RESULTS_DIR, "performance.csv")
if not os.path.exists(equity_path):
st.error(f"No equity.csv in `{RESULTS_DIR}/`.")
st.stop()
equity = pd.read_csv(equity_path, index_col="date", parse_dates=True)["equity"]
trades = pd.read_csv(trades_path) if os.path.exists(trades_path) else pd.DataFrame()
perf_df = (
pd.read_csv(perf_path, index_col="metric")
if os.path.exists(perf_path)
else None
)
def get_metric(name: str) -> float:
if perf_df is not None and name in perf_df.index:
return float(perf_df.loc[name, "value"])
return float("nan")
returns = equity.pct_change().dropna()
rolling_max = equity.cummax()
drawdown = (equity - rolling_max) / rolling_max
perf = dict(
total_return=get_metric("total_return"),
cagr=get_metric("cagr"),
sharpe=get_metric("sharpe"),
max_dd=get_metric("max_drawdown"),
volatility=get_metric("volatility"),
calmar=get_metric("calmar"),
drawdown=drawdown,
returns=returns,
)
render_results(equity, perf, trades, benchmark, initial_capital)