""" 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( """ """, 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)