diff --git a/backtester/.gitignore b/backtester/.gitignore new file mode 100644 index 0000000..6c6083f --- /dev/null +++ b/backtester/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +.cache/ +*.png + +# Forward-tester runtime state (per-machine, not source) +state.json +log.csv diff --git a/backtester/FORWARD_TEST.md b/backtester/FORWARD_TEST.md new file mode 100644 index 0000000..bbb905f --- /dev/null +++ b/backtester/FORWARD_TEST.md @@ -0,0 +1,115 @@ +# Paper-Trading Forward Tester + +A single-shot forward tester that **reuses the exact strategy code** from the +backtester. It imports `Strategy` / `SmaCrossover` from `strategy.py` and the +metrics from `metrics.py` — the trading logic is never reimplemented, so +forward results are directly comparable to the backtest. + +## Files + +| File | Responsibility | +|------|---------------| +| `broker_paper.py` | `PaperBroker` — simulated account: cash, positions, trade log, same cost model as the backtester. Serializes to `state.json`. | +| `runner.py` | The live loop. Runs **once per invocation** (cron-friendly, not an infinite loop). Fetches latest bars, gets the signal, trades, logs, saves state. | +| `report.py` | Reads `log.csv` + `state.json`, computes the **same metrics** as `metrics.py`. | + +Runtime artifacts (gitignored): +- `state.json` — full broker state, persists between runs. +- `log.csv` — one timestamped row per invocation (the forward equity curve). + +## Quick start + +```bash +cd backtester +pip install -r requirements.txt + +python runner.py # one trading day's decision +python report.py # performance so far +``` + +Each `runner.py` call prints a one-line status, e.g.: + +``` +[2026-06-04T20:05:00+00:00] SPY bar=2026-06-04 price=531.20 signal=1 -> LONG | BUY 18.7654 @ 531.86 | equity=$9,981.34 +``` + +## Configuration + +All knobs are at the top of `runner.py`: + +```python +TICKER = "SPY" +INITIAL_CAPITAL = 10_000.0 +COMMISSION = 0.001 # 0.10 % per side — same as backtester +SLIPPAGE = 0.0005 # 0.05 % per side — same as backtester +STRATEGY = SmaCrossover(fast=50, slow=200) +LOOKBACK = "2y" # enough history to warm up SMA(200) +``` + +To forward-test a different strategy, import your `Strategy` subclass and +assign it to `STRATEGY` — exactly the same class you backtested. + +## No look-ahead — how to verify + +Two clearly-commented spots in `runner.py`: + +1. **Signal**: `STRATEGY.generate_signals(df)` runs over the full history of + **closed** bars; we read `signals.iloc[-1]` — the decision from the most + recent closed bar. `df` contains no future bar. +2. **Execution price** (`*** REAL-TIME PRICE USED HERE ***`): we transact at + `df["close"].iloc[-1]`, the latest real observed price — never a future one. + +Because the script is single-shot and only ever sees data up to "now", it +physically cannot read future bars. + +## Scheduling with cron (Linux / macOS) + +Run once each weekday shortly after the US market close (16:00 ET). +Edit your crontab: + +```bash +crontab -e +``` + +Add a line (adjust the path and Python interpreter to your machine). This runs +at 16:10 on weekdays, in the server's local time: + +```cron +10 16 * * 1-5 cd /absolute/path/to/backtester && /usr/bin/python3 runner.py >> runner.out 2>&1 +``` + +- `10 16` → 16:10 +- `* *` → every day-of-month, every month +- `1-5` → Monday–Friday only + +If your machine's clock isn't in US Eastern time, schedule for the equivalent +local time (e.g. `10 22 * * 1-5` for 22:10 CET ≈ 16:10 ET in winter), or set +`CRON_TZ=America/New_York` at the top of the crontab: + +```cron +CRON_TZ=America/New_York +10 16 * * 1-5 cd /absolute/path/to/backtester && /usr/bin/python3 runner.py >> runner.out 2>&1 +``` + +### macOS note +`cron` works on macOS but only fires if the machine is awake. For a laptop, +`launchd` (a `.plist` with `StartCalendarInterval`) is more reliable. cron is +fine for an always-on machine or server. + +## Resetting the test + +Delete the runtime state — the next `runner.py` run starts fresh from +`INITIAL_CAPITAL`: + +```bash +rm state.json log.csv +``` + +## Comparing forward vs. backtest + +`report.py` calls the **same** `metrics.compute_metrics` used by the +backtester. Run your backtest (`run.py`) and the forward report (`report.py`) +for the same strategy and compare the metric tables side by side — total +return, CAGR, Sharpe, max drawdown, win rate, profit factor, trade count. +Large divergence usually means the backtest was over-fit or the live cost +assumptions are off. diff --git a/backtester/README.md b/backtester/README.md new file mode 100644 index 0000000..f938458 --- /dev/null +++ b/backtester/README.md @@ -0,0 +1,107 @@ +# Python Event-Driven Backtester + +A clean, dependency-minimal backtester built from scratch using only +`pandas`, `numpy`, `yfinance`, and `matplotlib`. + +## Quick start + +```bash +cd backtester +pip install -r requirements.txt +python run.py +``` + +This downloads SPY (2010–2024), runs an SMA 50/200 crossover strategy, +prints a metrics table, and saves `backtest_results.png`. + +--- + +## Module overview + +| File | Responsibility | +|------|---------------| +| `data.py` | Fetch OHLCV from yfinance; cache as Parquet in `.cache/` | +| `strategy.py` | `Strategy` base class + `SmaCrossover` example | +| `engine.py` | Bar-by-bar loop; no look-ahead bias; transaction costs | +| `metrics.py` | Total return, CAGR, Sharpe, max DD, win rate, profit factor | +| `plot.py` | Equity curve with trade markers + drawdown panel | +| `run.py` | Wire-up; all user parameters live here | + +--- + +## How look-ahead bias is prevented + +The engine processes bars in order. At bar `i`: + +``` +signal = signals.iloc[i - 1] # determined at close of bar i-1 +exec_px = df.iloc[i]["open"] # filled at open of bar i ← no future data +mtm_px = df.iloc[i]["close"] # used only for valuation, not decisions +``` + +`strategy.generate_signals()` is called once on the full DataFrame before +the loop starts, but signals are consumed one bar late, so no future close +is ever used to make a trade decision. + +--- + +## Adding a new strategy + +1. Open `strategy.py`. +2. Subclass `Strategy` and implement `generate_signals`: + +```python +class MyStrategy(Strategy): + def generate_signals(self, df: pd.DataFrame) -> pd.Series: + # df has columns: open, high, low, close, volume + # Return an integer Series aligned to df.index: + # 1 = long + # 0 = flat + # -1 = short + signal = pd.Series(0, index=df.index, dtype=int) + # ... your logic here, using only df["close"].rolling(...) etc. ... + return signal +``` + +3. In `run.py`, replace the strategy line: + +```python +from strategy import MyStrategy +strategy = MyStrategy(...) +``` + +**Rules to avoid look-ahead bias inside `generate_signals`:** +- Only use rolling/expanding operations on `df["close"]` (or other OHLCV columns). +- Never use `.shift(-n)` with a negative shift (that reads future bars). +- Never index `df.iloc[i+k]` for `k > 0`. + +--- + +## Changing parameters + +Everything is in `run.py`: + +```python +TICKER = "AAPL" +START = "2015-01-01" +END = "2024-01-01" +INITIAL_CAPITAL = 50_000.0 +COMMISSION = 0.0005 # 0.05 % +SLIPPAGE = 0.0002 # 0.02 % + +strategy = SmaCrossover(fast=20, slow=100) +``` + +--- + +## Transaction costs model + +For each trade (entry or exit), the fill price is adjusted: + +| Action | Effective fill price | +|--------|---------------------| +| Buy (long entry / short cover) | `open × (1 + commission + slippage)` | +| Sell (long exit / short entry) | `open × (1 − commission − slippage)` | + +With defaults (0.10 % commission + 0.05 % slippage), a round-trip costs +approximately **0.30 %** of position value. diff --git a/backtester/broker_paper.py b/backtester/broker_paper.py new file mode 100644 index 0000000..844b422 --- /dev/null +++ b/backtester/broker_paper.py @@ -0,0 +1,163 @@ +""" +broker_paper.py — Simulated brokerage account for forward (paper) trading. + +This is the ONLY place that touches money. It mirrors the cost model and the +position/PnL accounting of engine.py exactly, so forward results are directly +comparable to backtest results. + +Transaction costs (identical to the backtester): + Buy (long entry / short cover) : price × (1 + commission + slippage) + Sell (long exit / short entry) : price × (1 - commission - slippage) + +Position convention (identical to the backtester): + 1 = long, 0 = flat, -1 = short + +State is fully serialized to state.json so nothing is lost between cron runs. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +class PaperBroker: + def __init__( + self, + initial_capital: float = 10_000.0, + commission: float = 0.001, # 0.10 % per side + slippage: float = 0.0005, # 0.05 % per side + ): + self.initial_capital = float(initial_capital) + self.commission = float(commission) + self.slippage = float(slippage) + + self.cash: float = float(initial_capital) + self.shares: float = 0.0 # >0 long, <0 short, 0 flat + self.current_signal: int = 0 # target position we currently hold + + # Open-position bookkeeping (for building round-trip trades). + self.entry_price: float = 0.0 + self.entry_date: str | None = None + + self.trade_log: list[dict] = [] + + # ── Order execution ───────────────────────────────────────────────────── + def market_order( + self, + ticker: str, + target_position: int, + price: float, + timestamp: str, + ) -> str: + """ + Move the account to `target_position` ∈ {-1, 0, 1} at `price`. + + `price` MUST be a real, already-observed market price (see runner.py — + we use the latest available close). This method never sees future data; + it only acts on the price handed to it right now. + + Returns a short human-readable description of the action taken. + """ + if target_position == self.current_signal: + return "HOLD" + + actions: list[str] = [] + + # ── Step 1: close any existing position ─────────────────────────────── + if self.current_signal == 1: + # Exit long: sell at price, costs reduce the proceeds. + exit_px = price * (1.0 - self.commission - self.slippage) + pnl = self.shares * (exit_px - self.entry_price) + ret_pct = pnl / (self.shares * self.entry_price) * 100.0 + self.cash += self.shares * exit_px + self._record_trade(timestamp, "long", exit_px, abs(self.shares), pnl, ret_pct) + actions.append(f"SELL {self.shares:.4f} @ {exit_px:.4f}") + self.shares = 0.0 + + elif self.current_signal == -1: + # Cover short: buy back at price, costs increase the cost. + exit_px = price * (1.0 + self.commission + self.slippage) + pnl = self.shares * (exit_px - self.entry_price) # shares < 0 + ret_pct = pnl / (abs(self.shares) * self.entry_price) * 100.0 + self.cash += self.shares * exit_px # shares < 0 → cash down + self._record_trade(timestamp, "short", exit_px, abs(self.shares), pnl, ret_pct) + actions.append(f"COVER {abs(self.shares):.4f} @ {exit_px:.4f}") + self.shares = 0.0 + + # ── Step 2: open the new position ───────────────────────────────────── + if target_position == 1: + # Enter long: buy at price, costs increase the price paid. + entry_px = price * (1.0 + self.commission + self.slippage) + self.shares = self.cash / entry_px # deploy full available cash + self.cash -= self.shares * entry_px + self.entry_price = entry_px + self.entry_date = timestamp + actions.append(f"BUY {self.shares:.4f} @ {entry_px:.4f}") + + elif target_position == -1: + # Enter short: sell at price, costs reduce the proceeds received. + entry_px = price * (1.0 - self.commission - self.slippage) + n = self.cash / entry_px # shares to short (positive) + self.cash += n * entry_px # receive short proceeds + self.shares = -n + self.entry_price = entry_px + self.entry_date = timestamp + actions.append(f"SHORT {n:.4f} @ {entry_px:.4f}") + + self.current_signal = target_position + return " | ".join(actions) if actions else "FLAT" + + # ── Valuation ─────────────────────────────────────────────────────────── + def equity(self, price: float) -> float: + """Mark-to-market account value at `price` (a real observed price).""" + return self.cash + self.shares * price + + def position_label(self) -> str: + return {1: "LONG", 0: "FLAT", -1: "SHORT"}[self.current_signal] + + # ── Trade-log helper (mirrors engine._make_trade) ───────────────────────── + def _record_trade(self, exit_date, direction, exit_price, shares, pnl, ret_pct): + self.trade_log.append({ + "entry_date": self.entry_date, + "exit_date": exit_date, + "direction": direction, + "entry_price": round(self.entry_price, 4), + "exit_price": round(exit_price, 4), + "shares": round(shares, 4), + "pnl": round(pnl, 2), + "return_pct": round(ret_pct, 3), + }) + + # ── Persistence ─────────────────────────────────────────────────────────── + def to_dict(self) -> dict: + return { + "initial_capital": self.initial_capital, + "commission": self.commission, + "slippage": self.slippage, + "cash": self.cash, + "shares": self.shares, + "current_signal": self.current_signal, + "entry_price": self.entry_price, + "entry_date": self.entry_date, + "trade_log": self.trade_log, + } + + def save(self, path: str | Path) -> None: + Path(path).write_text(json.dumps(self.to_dict(), indent=2, default=str)) + + @classmethod + def load(cls, path: str | Path) -> "PaperBroker": + data = json.loads(Path(path).read_text()) + broker = cls( + initial_capital=data["initial_capital"], + commission=data["commission"], + slippage=data["slippage"], + ) + broker.cash = data["cash"] + broker.shares = data["shares"] + broker.current_signal = data["current_signal"] + broker.entry_price = data["entry_price"] + broker.entry_date = data["entry_date"] + broker.trade_log = data["trade_log"] + return broker diff --git a/backtester/data.py b/backtester/data.py new file mode 100644 index 0000000..40e5882 --- /dev/null +++ b/backtester/data.py @@ -0,0 +1,45 @@ +""" +data.py — Fetch and cache OHLCV data via yfinance. + +Parquet cache lives in .cache/ so repeated runs skip the network. +Cache key encodes ticker + date range, so changing any parameter +forces a fresh download. +""" + +from pathlib import Path +import pandas as pd +import yfinance as yf + +CACHE_DIR = Path(__file__).parent / ".cache" + + +def get_data(ticker: str, start: str, end: str) -> pd.DataFrame: + """Return a daily OHLCV DataFrame for `ticker` over [start, end). + + Columns (lowercase): open, high, low, close, volume + Index: DatetimeIndex (UTC-naive) + """ + CACHE_DIR.mkdir(exist_ok=True) + cache_file = CACHE_DIR / f"{ticker}_{start}_{end}.parquet" + + if cache_file.exists(): + print(f"[data] Loading from cache: {cache_file.name}") + return pd.read_parquet(cache_file) + + print(f"[data] Downloading {ticker} from {start} to {end} …") + df = yf.download(ticker, start=start, end=end, auto_adjust=True, progress=False) + + if df.empty: + raise ValueError(f"No data returned for ticker '{ticker}' ({start} → {end})") + + # yfinance ≥ 0.2 returns MultiIndex columns for single tickers; flatten. + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.droplevel(1) + + df.columns = [c.lower() for c in df.columns] + df.index = pd.to_datetime(df.index) + df.sort_index(inplace=True) + + df.to_parquet(cache_file) + print(f"[data] Cached to {cache_file.name} ({len(df)} rows)") + return df diff --git a/backtester/engine.py b/backtester/engine.py new file mode 100644 index 0000000..eab0f11 --- /dev/null +++ b/backtester/engine.py @@ -0,0 +1,198 @@ +""" +engine.py — Event-driven bar-by-bar backtesting engine. + +Design contract (look-ahead bias prevention) +───────────────────────────────────────────── + Bar i-1 close → signal computed (strategy.generate_signals) + Bar i open → signal EXECUTED (order filled here) + Bar i close → portfolio marked-to-market (equity recorded here) + +The key invariant is enforced in the main loop: + + pending_signal = signals.iloc[i - 1] # ← yesterday's signal + open_px = df.iloc[i]["open"] # ← today's open (execution price) + +We never touch signals.iloc[i] or df.iloc[i]["close"] during the execution +phase. The close is only read afterward, purely for valuation. + +Transaction costs +───────────────── + Long entry : effective_price = open × (1 + commission + slippage) + Long exit : effective_price = open × (1 - commission - slippage) + Short entry : effective_price = open × (1 - commission - slippage) (we receive less) + Short exit : effective_price = open × (1 + commission + slippage) (we pay more) + +Position accounting +─────────────────── + `shares` > 0 → long + `shares` < 0 → short + `shares` == 0 → flat + + Equity at any bar = cash + shares × close_price + + For shorts the cash balance includes the short-sale proceeds, so + equity = cash (large) + shares (negative) × close correctly decreases + as price rises — no special-casing needed. + +Trade log fields +──────────────── + entry_date, exit_date, direction ('long'/'short'), + entry_price, exit_price, shares (always positive), + pnl ($), return_pct (%). +""" + +from __future__ import annotations + +import pandas as pd +from strategy import Strategy + + +def run_backtest( + df: pd.DataFrame, + strategy: Strategy, + initial_capital: float = 10_000.0, + commission: float = 0.001, # 0.10 % per side + slippage: float = 0.0005, # 0.05 % per side +) -> tuple[pd.Series, list[dict]]: + """ + Run a full bar-by-bar backtest. + + Parameters + ---------- + df : OHLCV DataFrame (columns: open, high, low, close, volume). + strategy : Any Strategy subclass. + initial_capital : Starting cash in account currency. + commission : One-way commission as a fraction (0.001 = 0.1 %). + slippage : One-way slippage as a fraction (0.0005 = 0.05 %). + + Returns + ------- + equity_curve : pd.Series — portfolio value at each bar's close. + trade_log : list[dict] — one entry per completed round-trip. + """ + # ── Compute all signals up-front ────────────────────────────────────────── + # signals[t] is based on data through close of bar t. + # It is safe to compute them all now because the *execution* still happens + # one bar later (see loop below). + signals: pd.Series = strategy.generate_signals(df) + + # ── State variables ─────────────────────────────────────────────────────── + cash: float = float(initial_capital) + shares: float = 0.0 # positive = long, negative = short + current_signal: int = 0 # what position we currently hold + + entry_price: float = 0.0 + entry_date: pd.Timestamp | None = None + + equity_values: list[float] = [] + trade_log: list[dict] = [] + + # ── Main bar loop ───────────────────────────────────────────────────────── + for i in range(len(df)): + date = df.index[i] + row = df.iloc[i] + + # ── EXECUTION PHASE ─────────────────────────────────────────────────── + # We only act from bar 1 onward; bar 0 has no prior signal. + if i > 0: + # Look-ahead prevention: use signal from the PREVIOUS bar's close. + # Never read signals.iloc[i] here. + pending_signal: int = int(signals.iloc[i - 1]) + + if pending_signal != current_signal: + # Execute at the OPEN of the current bar — not yesterday's close. + open_px: float = float(row["open"]) + + # ── Step 1: Close existing position (if any) ───────────────── + if current_signal == 1: + # Exit long: sell at open, costs reduce the price received. + exit_px = open_px * (1.0 - commission - slippage) + pnl = shares * (exit_px - entry_price) + ret_pct = pnl / (shares * entry_price) * 100.0 + cash += shares * exit_px + trade_log.append(_make_trade( + entry_date, date, "long", + entry_price, exit_px, shares, pnl, ret_pct, + )) + shares = 0.0 + + elif current_signal == -1: + # Cover short: buy back at open, costs increase the price paid. + exit_px = open_px * (1.0 + commission + slippage) + # shares < 0; formula gives positive PnL when price fell. + pnl = shares * (exit_px - entry_price) + ret_pct = pnl / (abs(shares) * entry_price) * 100.0 + cash += shares * exit_px # shares negative → cash decreases + trade_log.append(_make_trade( + entry_date, date, "short", + entry_price, exit_px, abs(shares), pnl, ret_pct, + )) + shares = 0.0 + + # ── Step 2: Enter new position ──────────────────────────────── + if pending_signal == 1: + # Enter long: buy at open, costs increase the price paid. + entry_px = open_px * (1.0 + commission + slippage) + shares = cash / entry_px # use full available capital + cash -= shares * entry_px # cash → ~0 (floating-point noise) + entry_price = entry_px + entry_date = date + + elif pending_signal == -1: + # Enter short: sell at open, costs reduce the price received. + # We receive short-sale proceeds; cash increases. + entry_px = open_px * (1.0 - commission - slippage) + n = cash / entry_px # shares to short (positive) + cash += n * entry_px # receive proceeds + shares = -n # mark position as short + entry_price = entry_px + entry_date = date + + current_signal = pending_signal + + # ── MARK-TO-MARKET PHASE ────────────────────────────────────────────── + # Portfolio value at the CLOSE of the current bar. + # This is purely for recording — it does NOT feed back into signals. + close_px: float = float(row["close"]) + equity_values.append(cash + shares * close_px) + + # ── Force-close any open position at the last bar's close ───────────────── + # This is a reporting-only liquidation, not a real execution. + # We use the raw close price (no transaction costs) so that + # sum(trade_log PnL) == equity.iloc[-1] - initial_capital exactly. + # The equity curve's last value is already MTM at the same close, so + # applying costs here would introduce an artificial discrepancy. + if shares != 0 and entry_date is not None: + last_close = float(df["close"].iloc[-1]) + last_date = df.index[-1] + + # exit_px = raw close (no costs — reporting liquidation) + exit_px = last_close + pnl = shares * (exit_px - entry_price) + ret_pct = pnl / (abs(shares) * entry_price) * 100.0 + direction = "long" if current_signal == 1 else "short" + trade_log.append(_make_trade( + entry_date, last_date, direction, + entry_price, exit_px, abs(shares), pnl, ret_pct, + )) + + equity_curve = pd.Series(equity_values, index=df.index, name="equity") + return equity_curve, trade_log + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _make_trade( + entry_date, exit_date, direction, + entry_price, exit_price, shares, pnl, return_pct, +) -> dict: + return { + "entry_date": entry_date, + "exit_date": exit_date, + "direction": direction, + "entry_price": round(entry_price, 4), + "exit_price": round(exit_price, 4), + "shares": round(shares, 4), + "pnl": round(pnl, 2), + "return_pct": round(return_pct, 3), + } diff --git a/backtester/metrics.py b/backtester/metrics.py new file mode 100644 index 0000000..af56fe1 --- /dev/null +++ b/backtester/metrics.py @@ -0,0 +1,89 @@ +""" +metrics.py — Performance metrics derived from the equity curve and trade log. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + + +def compute_metrics( + equity: pd.Series, + trade_log: list[dict], + initial_capital: float, +) -> dict: + """ + Compute a standard set of backtest performance metrics. + + Parameters + ---------- + equity : Daily equity curve (pd.Series with DatetimeIndex). + trade_log : List of completed trade dicts from engine.run_backtest(). + initial_capital : Starting capital used in the backtest. + + Returns + ------- + Ordered dict of metric name → value (already rounded). + """ + start = equity.index[0] + end = equity.index[-1] + n_years = (end - start).days / 365.25 + + # ── Returns ─────────────────────────────────────────────────────────────── + total_return_pct = (equity.iloc[-1] / initial_capital - 1.0) * 100.0 + cagr_pct = ((equity.iloc[-1] / initial_capital) ** (1.0 / n_years) - 1.0) * 100.0 + + # ── Risk ────────────────────────────────────────────────────────────────── + daily_rets = equity.pct_change().dropna() + sharpe = float("nan") + if daily_rets.std() > 0: + # Annualise assuming 252 trading days; risk-free rate = 0. + sharpe = (daily_rets.mean() / daily_rets.std()) * np.sqrt(252) + + rolling_peak = equity.cummax() + drawdown = (equity - rolling_peak) / rolling_peak + max_dd_pct = drawdown.min() * 100.0 + + # ── Trade statistics ────────────────────────────────────────────────────── + n_trades = len(trade_log) + win_rate = profit_factor = avg_trade_pnl = float("nan") + + if n_trades > 0: + pnls = [t["pnl"] for t in trade_log] + wins = [p for p in pnls if p > 0] + losses = [p for p in pnls if p <= 0] + + win_rate = len(wins) / n_trades * 100.0 + avg_trade_pnl = sum(pnls) / n_trades + + gross_profit = sum(wins) + gross_loss = abs(sum(losses)) + profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf") + + return { + "Period": f"{start.date()} → {end.date()}", + "Initial Capital ($)": round(initial_capital, 2), + "Final Equity ($)": round(float(equity.iloc[-1]), 2), + "Total Return (%)": round(total_return_pct, 2), + "CAGR (%)": round(cagr_pct, 2), + "Sharpe Ratio": round(sharpe, 3), + "Max Drawdown (%)": round(max_dd_pct, 2), + "Num Trades": n_trades, + "Win Rate (%)": round(win_rate, 2), + "Profit Factor": round(profit_factor, 3), + "Avg Trade PnL ($)": round(avg_trade_pnl, 2), + } + + +def print_metrics(metrics: dict) -> None: + """Pretty-print the metrics dict as a table.""" + width = 46 + print() + print("╔" + "═" * width + "╗") + print("║{:^{w}}║".format(" BACKTEST RESULTS ", w=width)) + print("╠" + "═" * width + "╣") + for key, val in metrics.items(): + print("║ {:<26}{:>16} ║".format(key, str(val))) + print("╚" + "═" * width + "╝") + print() diff --git a/backtester/plot.py b/backtester/plot.py new file mode 100644 index 0000000..8cbffed --- /dev/null +++ b/backtester/plot.py @@ -0,0 +1,110 @@ +""" +plot.py — Visualise backtest results. + +Two-panel layout: + Top : equity curve with entry (▲) and exit (▼) markers. + Bottom : underwater equity / drawdown chart. +""" + +from __future__ import annotations + +import matplotlib.dates as mdates +import matplotlib.pyplot as plt +import matplotlib.ticker as mticker +import numpy as np +import pandas as pd + + +def plot_results( + equity: pd.Series, + trade_log: list[dict], + title: str = "Backtest Results", + save_path: str | None = "backtest_results.png", +) -> None: + """ + Parameters + ---------- + equity : Daily equity curve from engine.run_backtest(). + trade_log : Completed trade list from engine.run_backtest(). + title : Figure suptitle. + save_path : If given, save PNG to this path; also calls plt.show(). + """ + fig, (ax_eq, ax_dd) = plt.subplots( + 2, 1, + figsize=(14, 8), + sharex=True, + gridspec_kw={"height_ratios": [3, 1]}, + ) + fig.suptitle(title, fontsize=14, fontweight="bold", y=0.98) + + # ── Top panel: equity curve ─────────────────────────────────────────────── + ax_eq.plot(equity.index, equity.values, color="#2563EB", linewidth=1.5, + label="Portfolio Equity", zorder=2) + + # Shade the area under the curve + ax_eq.fill_between(equity.index, equity.values, equity.values.min(), + alpha=0.06, color="#2563EB") + + # Entry / exit markers from trade log + for trade in trade_log: + entry_dt = trade["entry_date"] + exit_dt = trade["exit_date"] + + # Only mark if the date exists in the equity index + if entry_dt in equity.index: + ax_eq.scatter( + entry_dt, equity[entry_dt], + marker="^", color="#16A34A", s=80, zorder=5, + label="Entry" if trade is trade_log[0] else "", + ) + if exit_dt in equity.index: + ax_eq.scatter( + exit_dt, equity[exit_dt], + marker="v", color="#DC2626", s=80, zorder=5, + label="Exit" if trade is trade_log[0] else "", + ) + + ax_eq.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"${x:,.0f}")) + ax_eq.set_ylabel("Portfolio Value", fontsize=10) + ax_eq.grid(True, alpha=0.25) + ax_eq.legend(fontsize=9, loc="upper left") + + # ── Bottom panel: drawdown ──────────────────────────────────────────────── + rolling_peak = equity.cummax() + drawdown_pct = (equity - rolling_peak) / rolling_peak * 100.0 + + ax_dd.fill_between(drawdown_pct.index, drawdown_pct.values, 0, + color="#DC2626", alpha=0.45, label="Drawdown") + ax_dd.plot(drawdown_pct.index, drawdown_pct.values, color="#DC2626", + linewidth=0.8) + + max_dd_idx = drawdown_pct.idxmin() + max_dd_val = drawdown_pct.min() + ax_dd.annotate( + f"{max_dd_val:.1f}%", + xy=(max_dd_idx, max_dd_val), + xytext=(10, -15), + textcoords="offset points", + fontsize=8, + color="#DC2626", + arrowprops=dict(arrowstyle="->", color="#DC2626", lw=0.8), + ) + + ax_dd.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"{x:.0f}%")) + ax_dd.set_ylabel("Drawdown", fontsize=10) + ax_dd.set_xlabel("Date", fontsize=10) + ax_dd.grid(True, alpha=0.25) + ax_dd.legend(fontsize=9, loc="lower left") + + # Shared x-axis formatting + ax_dd.xaxis.set_major_formatter(mdates.DateFormatter("%Y")) + ax_dd.xaxis.set_major_locator(mdates.YearLocator()) + fig.autofmt_xdate(rotation=0, ha="center") + + plt.tight_layout() + + if save_path: + fig.savefig(save_path, dpi=150, bbox_inches="tight") + print(f"[plot] Saved to {save_path}") + + plt.show() diff --git a/backtester/report.py b/backtester/report.py new file mode 100644 index 0000000..deb6fd0 --- /dev/null +++ b/backtester/report.py @@ -0,0 +1,71 @@ +""" +report.py — Forward-test performance report. + +Reads log.csv (the equity time-series logged by runner.py) and the trade log +inside state.json, then computes performance metrics using the SAME function +as the backtester (metrics.compute_metrics). This makes forward results +directly comparable to the backtest. + +Usage: + python report.py +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd + +# ── Reuse the EXACT metrics code from the backtester (no reimplementation) ── +from metrics import compute_metrics, print_metrics + +HERE = Path(__file__).parent +STATE_FILE = HERE / "state.json" +LOG_FILE = HERE / "log.csv" + + +def build_equity_curve(log_path: Path) -> pd.Series: + """ + Reconstruct the equity curve from log.csv. + + Each runner invocation appends one row with the marked-to-market equity at + that bar's price, so the 'equity' column over time IS the forward-test + equity curve. We index it by bar_date (one point per trading day). + """ + df = pd.read_csv(log_path) + df["bar_date"] = pd.to_datetime(df["bar_date"]) + # If a day was logged more than once (e.g. manual re-run), keep the last. + df = df.drop_duplicates(subset="bar_date", keep="last") + df.set_index("bar_date", inplace=True) + df.sort_index(inplace=True) + return df["equity"].astype(float) + + +def main() -> None: + if not LOG_FILE.exists() or not STATE_FILE.exists(): + print("No forward-test data yet. Run runner.py at least once first.") + return + + equity = build_equity_curve(LOG_FILE) + if len(equity) < 2: + print(f"Only {len(equity)} data point(s) logged so far — need at least 2 " + "to compute returns. Keep running runner.py daily.") + return + + state = json.loads(STATE_FILE.read_text()) + trade_log = state["trade_log"] + initial_capital = state["initial_capital"] + + # Same metrics function the backtester uses → apples-to-apples comparison. + metrics = compute_metrics(equity, trade_log, initial_capital) + + print("\n=== FORWARD (PAPER) TEST REPORT ===") + print(f"Logged trading days: {len(equity)}") + print_metrics(metrics) + print("Compare these against your backtest's metrics.py output for the " + "same strategy to see how forward performance tracks the backtest.\n") + + +if __name__ == "__main__": + main() diff --git a/backtester/requirements.txt b/backtester/requirements.txt new file mode 100644 index 0000000..256a99a --- /dev/null +++ b/backtester/requirements.txt @@ -0,0 +1,5 @@ +pandas>=2.0 +numpy>=1.24 +yfinance>=0.2.40 +matplotlib>=3.7 +pyarrow>=14.0 diff --git a/backtester/run.py b/backtester/run.py new file mode 100644 index 0000000..c6a3d1e --- /dev/null +++ b/backtester/run.py @@ -0,0 +1,54 @@ +""" +run.py — Entry point: wire data → strategy → engine → metrics → plot. + +All user-configurable parameters are at the top of this file. +""" + +from data import get_data +from engine import run_backtest +from metrics import compute_metrics, print_metrics +from plot import plot_results +from strategy import SmaCrossover + +# ── Configuration ───────────────────────────────────────────────────────────── + +TICKER = "SPY" +START = "2010-01-01" +END = "2024-01-01" + +INITIAL_CAPITAL: float = 10_000.0 +COMMISSION: float = 0.001 # 0.10 % per side (round-turn = 0.20 %) +SLIPPAGE: float = 0.0005 # 0.05 % per side (round-turn = 0.10 %) + +# ── Strategy ────────────────────────────────────────────────────────────────── +# Swap this for any other Strategy subclass defined in strategy.py. + +strategy = SmaCrossover(fast=50, slow=200) + +# ── Run ─────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + print(f"\nRunning backtest: {strategy} on {TICKER} ({START} → {END})") + print(f"Capital: ${INITIAL_CAPITAL:,.0f} | " + f"Commission: {COMMISSION*100:.2f}% | " + f"Slippage: {SLIPPAGE*100:.3f}%\n") + + df = get_data(TICKER, START, END) + + equity, trade_log = run_backtest( + df, + strategy, + initial_capital=INITIAL_CAPITAL, + commission=COMMISSION, + slippage=SLIPPAGE, + ) + + metrics = compute_metrics(equity, trade_log, INITIAL_CAPITAL) + print_metrics(metrics) + + plot_results( + equity, + trade_log, + title=f"{strategy} — {TICKER} ({START} → {END})", + save_path="backtest_results.png", + ) diff --git a/backtester/runner.py b/backtester/runner.py new file mode 100644 index 0000000..aa2eb54 --- /dev/null +++ b/backtester/runner.py @@ -0,0 +1,137 @@ +""" +runner.py — Paper-trading forward tester. Runs ONCE per invocation. + +Designed to be triggered by cron/launchd once per trading day (see README), +NOT to run as an infinite loop. + +Flow per invocation: + 1. Load PaperBroker from state.json (or initialize on first run). + 2. Fetch the latest available daily bars via yfinance. + 3. Feed them to the IMPORTED strategy (same code as the backtester) to get + the current target signal. + 4. Execute any position change through PaperBroker at the latest real price. + 5. Append the decision to log.csv (timestamped). + 6. Save broker state back to state.json. + 7. Print a one-line status. + +It reuses strategy.py verbatim — the trading logic is imported, never copied. +""" + +from __future__ import annotations + +import csv +from datetime import datetime, timezone +from pathlib import Path + +import yfinance as yf + +from broker_paper import PaperBroker +# ── Reuse the EXACT strategy code from the backtester (no reimplementation) ── +from strategy import SmaCrossover # noqa: F401 (swap for any Strategy subclass) + +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ CONFIGURATION — edit here ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ +TICKER = "SPY" +INITIAL_CAPITAL = 10_000.0 +COMMISSION = 0.001 # 0.10 % per side (same as backtester) +SLIPPAGE = 0.0005 # 0.05 % per side (same as backtester) + +# The strategy instance — identical class to the backtest. +STRATEGY = SmaCrossover(fast=50, slow=200) + +# How much history to pull so the strategy's slowest indicator is warmed up. +# SMA(200) needs ≥ 200 bars; "2y" of daily data (~500 bars) is comfortable. +LOOKBACK = "2y" + +# Schedule assumption: this script is run once per trading day, shortly after +# the close, by cron/launchd (see README). It is intentionally single-shot. + +# File locations (next to this script). +HERE = Path(__file__).parent +STATE_FILE = HERE / "state.json" +LOG_FILE = HERE / "log.csv" + +LOG_HEADER = ["timestamp", "bar_date", "signal", "action", "price", "position", "equity"] + + +def fetch_latest_bars(ticker: str, lookback: str): + """ + Download recent daily OHLCV. + + The most recent row is the latest CLOSED daily bar available from the data + provider. We never request or use a bar dated in the future. + """ + df = yf.download(ticker, period=lookback, interval="1d", + auto_adjust=True, progress=False) + if df.empty: + raise RuntimeError(f"No data returned for {ticker}") + if hasattr(df.columns, "droplevel") and df.columns.nlevels > 1: + df.columns = df.columns.droplevel(1) + df.columns = [c.lower() for c in df.columns] + df.sort_index(inplace=True) + return df + + +def append_log(row: dict) -> None: + """Append one decision row to log.csv, writing the header on first use.""" + new_file = not LOG_FILE.exists() + with LOG_FILE.open("a", newline="") as f: + writer = csv.DictWriter(f, fieldnames=LOG_HEADER) + if new_file: + writer.writeheader() + writer.writerow(row) + + +def main() -> None: + # ── 1. Load or initialize broker state ──────────────────────────────────── + if STATE_FILE.exists(): + broker = PaperBroker.load(STATE_FILE) + else: + broker = PaperBroker(INITIAL_CAPITAL, COMMISSION, SLIPPAGE) + + # ── 2. Fetch latest bars (real, already-closed market data) ─────────────── + df = fetch_latest_bars(TICKER, LOOKBACK) + + # ── 3. Compute the strategy signal on observed data only ────────────────── + # generate_signals() runs on the full history of CLOSED bars. We take the + # signal on the LAST row — i.e. the decision implied by the most recent + # closed bar. No future bar exists in df, so there is no look-ahead. + signals = STRATEGY.generate_signals(df) + target_signal = int(signals.iloc[-1]) + + # ── 4. Execute at the latest REAL price ─────────────────────────────────── + # *** REAL-TIME PRICE USED HERE *** + # We execute at the close of the latest available bar. In live forward + # testing this is the most recent real, observed price — it is NOT a future + # price. (A signal derived from bar t is acted on at bar t's own close, + # which is the price you could realistically transact at right after close.) + latest_price = float(df["close"].iloc[-1]) + bar_date = df.index[-1].date().isoformat() + now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds") + + action = broker.market_order(TICKER, target_signal, latest_price, now_iso) + + # ── 5. Mark-to-market and log the decision ──────────────────────────────── + equity = broker.equity(latest_price) + append_log({ + "timestamp": now_iso, + "bar_date": bar_date, + "signal": target_signal, + "action": action, + "price": round(latest_price, 4), + "position": broker.position_label(), + "equity": round(equity, 2), + }) + + # ── 6. Persist broker state ─────────────────────────────────────────────── + broker.save(STATE_FILE) + + # ── 7. One-line status ──────────────────────────────────────────────────── + print(f"[{now_iso}] {TICKER} bar={bar_date} price={latest_price:.2f} " + f"signal={target_signal} -> {broker.position_label()} " + f"| {action} | equity=${equity:,.2f}") + + +if __name__ == "__main__": + main() diff --git a/backtester/strategy.py b/backtester/strategy.py new file mode 100644 index 0000000..3af976b --- /dev/null +++ b/backtester/strategy.py @@ -0,0 +1,74 @@ +""" +strategy.py — Base Strategy interface and concrete implementations. + +To add a new strategy: + 1. Subclass Strategy. + 2. Implement generate_signals(df) → pd.Series. + 3. Return an integer Series aligned to df.index: + 1 = go / stay long + 0 = go / stay flat + -1 = go / stay short + 4. Pass your instance to engine.run_backtest(). + +IMPORTANT: generate_signals() receives the FULL price DataFrame, but the +signal at index t must only use information available at the CLOSE of bar t +(i.e., df.iloc[:t+1]). The engine enforces the execution lag (see engine.py), +but it cannot prevent you from accidentally reading future data inside this +function. Stick to rolling indicators computed on the close series and you +will be fine. +""" + +from abc import ABC, abstractmethod +import pandas as pd + + +class Strategy(ABC): + @abstractmethod + def generate_signals(self, df: pd.DataFrame) -> pd.Series: + """ + Parameters + ---------- + df : DataFrame with at least a 'close' column, DatetimeIndex. + + Returns + ------- + pd.Series of int {-1, 0, 1} with the same index as df. + signal[t] is produced from data available at close of bar t; + the engine will execute it at the OPEN of bar t+1. + """ + + +class SmaCrossover(Strategy): + """ + Golden-cross / death-cross strategy. + + Rules: + fast_ma > slow_ma → signal = 1 (long) + fast_ma ≤ slow_ma → signal = 0 (flat) + + Bars where the slow MA is not yet defined (first `slow` bars) get signal 0. + """ + + def __init__(self, fast: int = 50, slow: int = 200): + if fast >= slow: + raise ValueError(f"fast ({fast}) must be < slow ({slow})") + self.fast = fast + self.slow = slow + + def generate_signals(self, df: pd.DataFrame) -> pd.Series: + close = df["close"] + + # Both MAs are computed only from past + current bar data + # (rolling with default min_periods = window ensures no partial windows). + fast_ma = close.rolling(self.fast).mean() + slow_ma = close.rolling(self.slow).mean() + + # Where slow_ma is NaN (warm-up period), we stay flat. + signal = pd.Series(0, index=df.index, dtype=int) + signal[fast_ma > slow_ma] = 1 + # fast_ma <= slow_ma already maps to 0 (default). + + return signal + + def __repr__(self) -> str: + return f"SmaCrossover(fast={self.fast}, slow={self.slow})" diff --git a/backtester/test_forward.py b/backtester/test_forward.py new file mode 100644 index 0000000..935d173 --- /dev/null +++ b/backtester/test_forward.py @@ -0,0 +1,157 @@ +""" +test_forward.py — Verify the paper-trading forward tester without network. + +Simulates many daily cron invocations by feeding synthetic bars one day at a +time. Each "day" mimics exactly what runner.main() does: + - build the df of CLOSED bars up to today + - get signal = strategy.generate_signals(df).iloc[-1] + - execute at df['close'].iloc[-1] (latest real price) + - append to log.csv, save state.json + +Then it checks: + 1. State persists across invocations (reload from disk each day). + 2. PaperBroker cost model matches engine.run_backtest exactly. + 3. report.build_equity_curve + compute_metrics run cleanly. +""" + +import csv +import sys +from pathlib import Path + +sys.path.insert(0, ".") + +import numpy as np +import pandas as pd + +from broker_paper import PaperBroker +from engine import run_backtest +from report import build_equity_curve +from metrics import compute_metrics +from strategy import SmaCrossover + +HERE = Path(__file__).parent +STATE = HERE / "state.json" +LOG = HERE / "log.csv" +LOG_HEADER = ["timestamp", "bar_date", "signal", "action", "price", "position", "equity"] + + +def make_data(n=400, seed=11): + rng = np.random.default_rng(seed) + dates = pd.bdate_range("2020-01-01", periods=n) + close = 100 * np.exp(np.cumsum(rng.normal(0.0005, 0.012, n))) + open_ = close * rng.uniform(0.996, 1.004, n) + high = np.maximum(open_, close) * rng.uniform(1.0, 1.008, n) + low = np.minimum(open_, close) * rng.uniform(0.992, 1.0, n) + vol = rng.integers(1e6, 5e6, n).astype(float) + return pd.DataFrame({"open": open_, "high": high, "low": low, + "close": close, "volume": vol}, index=dates) + + +def simulate_forward(df, strat, warmup): + """Replay each day as a separate cron invocation (reload state every time).""" + if STATE.exists(): + STATE.unlink() + if LOG.exists(): + LOG.unlink() + + # Start once warmup history exists so SMA(slow) is defined. + for end in range(warmup, len(df) + 1): + window = df.iloc[:end] # only CLOSED bars up to "today" + + # Fresh load each invocation — proves persistence works. + broker = PaperBroker.load(STATE) if STATE.exists() else \ + PaperBroker(10_000, 0.001, 0.0005) + + signals = strat.generate_signals(window) + target = int(signals.iloc[-1]) + price = float(window["close"].iloc[-1]) # latest real price + bar_date = window.index[-1].date().isoformat() + + action = broker.market_order("SYN", target, price, bar_date) + equity = broker.equity(price) + + new_file = not LOG.exists() + with LOG.open("a", newline="") as f: + w = csv.DictWriter(f, fieldnames=LOG_HEADER) + if new_file: + w.writeheader() + w.writerow({"timestamp": bar_date + "T20:00:00", "bar_date": bar_date, + "signal": target, "action": action, "price": round(price, 4), + "position": broker.position_label(), "equity": round(equity, 2)}) + broker.save(STATE) + + return PaperBroker.load(STATE) + + +def test_persistence_and_report(): + df = make_data() + strat = SmaCrossover(20, 50) + broker = simulate_forward(df, strat, warmup=50) + + equity_curve = build_equity_curve(LOG) + assert len(equity_curve) > 2, "Should have many logged days" + m = compute_metrics(equity_curve, broker.trade_log, broker.initial_capital) + assert "Sharpe Ratio" in m and "Max Drawdown (%)" in m + print(f"[PASS] State persisted across {len(equity_curve)} invocations; " + f"report computed ({len(broker.trade_log)} trades)") + return broker, df, strat + + +def test_cost_model_matches_backtester(): + """ + The forward broker, fed bar-by-bar at each CLOSE, must use the identical + cost model as engine.run_backtest. We can't expect identical equity (the + backtester fills at next OPEN, the forward tester at the same-bar CLOSE), + but a controlled single round-trip at the same price must produce identical + fills and PnL. + """ + price = 100.0 + # Forward broker: long then flat at the same price sequence. + b = PaperBroker(10_000, 0.001, 0.0005) + b.market_order("X", 1, price, "d1") + entry_shares = b.shares + entry_px_fwd = b.entry_price + b.market_order("X", 0, 110.0, "d2") + fwd_trade = b.trade_log[-1] + + # Hand-computed expected fills using the documented model. + exp_entry = 100.0 * (1 + 0.001 + 0.0005) + exp_exit = 110.0 * (1 - 0.001 - 0.0005) + exp_shares = 10_000 / exp_entry + exp_pnl = exp_shares * (exp_exit - exp_entry) + + assert abs(entry_px_fwd - exp_entry) < 1e-6, "Entry fill mismatch" + assert abs(entry_shares - exp_shares) < 1e-6, "Share count mismatch" + assert abs(fwd_trade["pnl"] - round(exp_pnl, 2)) < 0.01, "PnL mismatch" + print(f"[PASS] Cost model matches backtester: entry={exp_entry:.4f} " + f"exit={exp_exit:.4f} pnl={exp_pnl:.2f}") + + +def test_no_state_loss(): + """Reloading state.json must reproduce cash, shares, trades exactly.""" + b = PaperBroker(10_000, 0.001, 0.0005) + b.market_order("X", 1, 100.0, "d1") + b.market_order("X", -1, 105.0, "d2") + b.save(STATE) + reloaded = PaperBroker.load(STATE) + assert reloaded.cash == b.cash + assert reloaded.shares == b.shares + assert reloaded.current_signal == b.current_signal + assert reloaded.trade_log == b.trade_log + print("[PASS] No state loss on save/load round-trip") + + +if __name__ == "__main__": + test_no_state_loss() + test_cost_model_matches_backtester() + broker, df, strat = test_persistence_and_report() + + # Show the comparison the user cares about: forward report metrics. + from metrics import print_metrics + eq = build_equity_curve(LOG) + print_metrics(compute_metrics(eq, broker.trade_log, broker.initial_capital)) + + # Clean up runtime artifacts so the repo stays clean. + STATE.unlink(missing_ok=True) + LOG.unlink(missing_ok=True) + print("All forward-tester tests passed.") diff --git a/backtester/test_synthetic.py b/backtester/test_synthetic.py new file mode 100644 index 0000000..697f957 --- /dev/null +++ b/backtester/test_synthetic.py @@ -0,0 +1,155 @@ +""" +test_synthetic.py — Verify backtester correctness with generated price data. + +Runs without network access (no yfinance). Checks: + 1. No look-ahead bias: signal on bar t only executes at open of bar t+1. + 2. Transaction costs are applied correctly. + 3. Equity curve starts at initial_capital. + 4. Metrics are computed without errors. + 5. Trade log PnL sums to approx (final_equity - initial_capital). +""" + +import sys +sys.path.insert(0, ".") + +import numpy as np +import pandas as pd + +from engine import run_backtest +from metrics import compute_metrics, print_metrics +from plot import plot_results +from strategy import SmaCrossover, Strategy +import matplotlib +matplotlib.use("Agg") # non-interactive backend for CI / headless + + +def make_synthetic_ohlcv(n: int = 800, seed: int = 42) -> pd.DataFrame: + """Generate a simple trending synthetic price series.""" + rng = np.random.default_rng(seed) + dates = pd.bdate_range("2015-01-01", periods=n) + + # Brownian motion with slight upward drift + log_rets = rng.normal(0.0003, 0.012, size=n) + close = 100.0 * np.exp(np.cumsum(log_rets)) + + # Build OHLCV from close + noise = rng.uniform(0.995, 1.005, size=n) + open_ = close * rng.uniform(0.995, 1.005, size=n) + high = np.maximum(open_, close) * rng.uniform(1.000, 1.010, size=n) + low = np.minimum(open_, close) * rng.uniform(0.990, 1.000, size=n) + volume = rng.integers(1_000_000, 5_000_000, size=n).astype(float) + + return pd.DataFrame( + {"open": open_, "high": high, "low": low, "close": close, "volume": volume}, + index=dates, + ) + + +class AlwaysLong(Strategy): + """Trivial strategy: always long from bar 1 onward.""" + def generate_signals(self, df): + return pd.Series(1, index=df.index, dtype=int) + + +class NeverInMarket(Strategy): + """Always flat — equity should equal initial capital.""" + def generate_signals(self, df): + return pd.Series(0, index=df.index, dtype=int) + + +def test_flat_strategy(): + df = make_synthetic_ohlcv() + equity, trades = run_backtest(df, NeverInMarket(), initial_capital=10_000) + assert len(trades) == 0, "Flat strategy should produce no trades" + assert (equity == 10_000).all(), "Flat strategy equity should equal initial capital" + print("[PASS] Flat strategy: no trades, equity constant") + + +def test_equity_starts_at_capital(): + df = make_synthetic_ohlcv() + equity, _ = run_backtest(df, AlwaysLong(), initial_capital=12_345) + assert equity.iloc[0] == pytest_approx(12_345, rel=1e-6), \ + f"First equity bar should equal initial capital, got {equity.iloc[0]}" + print("[PASS] Equity starts at initial capital") + + +def pytest_approx(val, rel=1e-6): + """Tiny stand-in for pytest.approx for use without pytest.""" + class Approx: + def __eq__(self, other): + return abs(other - val) / abs(val) < rel + return Approx() + + +def test_trade_log_pnl_matches_equity(): + df = make_synthetic_ohlcv() + initial = 10_000.0 + equity, trades = run_backtest(df, SmaCrossover(20, 50), initial_capital=initial) + + total_pnl = sum(t["pnl"] for t in trades) + equity_gain = equity.iloc[-1] - initial + + # They should agree within floating-point rounding + assert abs(total_pnl - equity_gain) < 0.01, ( + f"Trade log PnL ({total_pnl:.2f}) ≠ equity gain ({equity_gain:.2f})" + ) + print(f"[PASS] Trade PnL ({total_pnl:.2f}) matches equity gain ({equity_gain:.2f})") + + +def test_no_lookahead(): + """ + Build a fake 'oracle' strategy that looks ahead — should NOT be able to + execute at the open it peeked at. Verify the engine ignores it and always + uses the NEXT bar's open. + """ + df = make_synthetic_ohlcv(n=100) + + class OracleSignalOnBar0(Strategy): + """Emits long on bar 0 only.""" + def generate_signals(self, df): + s = pd.Series(0, index=df.index, dtype=int) + s.iloc[0] = 1 # signal at bar 0 + return s + + equity, trades = run_backtest(df, OracleSignalOnBar0(), initial_capital=10_000) + + assert len(trades) >= 1, "Should produce at least one trade" + # The entry date must be bar 1 (not bar 0), because signal[0] executes at open[1]. + assert trades[0]["entry_date"] == df.index[1], ( + f"Entry should be bar 1 ({df.index[1].date()}), " + f"got {trades[0]['entry_date'].date()}" + ) + print(f"[PASS] No look-ahead: entry correctly at bar 1 ({df.index[1].date()})") + + +def test_transaction_costs_reduce_equity(): + df = make_synthetic_ohlcv() + + equity_free, _ = run_backtest(df, SmaCrossover(20, 50), + initial_capital=10_000, commission=0, slippage=0) + equity_costly, _ = run_backtest(df, SmaCrossover(20, 50), + initial_capital=10_000, commission=0.001, slippage=0.0005) + + assert equity_costly.iloc[-1] < equity_free.iloc[-1], \ + "Transaction costs should reduce final equity" + print(f"[PASS] Costs reduce equity: ${equity_costly.iloc[-1]:.2f} < ${equity_free.iloc[-1]:.2f}") + + +def test_metrics_and_plot(): + df = make_synthetic_ohlcv() + equity, trades = run_backtest(df, SmaCrossover(20, 50), initial_capital=10_000) + m = compute_metrics(equity, trades, 10_000) + print_metrics(m) + + plot_results(equity, trades, title="Synthetic Test", save_path="test_output.png") + print("[PASS] Metrics computed and plot saved to test_output.png") + + +if __name__ == "__main__": + test_flat_strategy() + test_equity_starts_at_capital() + test_trade_log_pnl_matches_equity() + test_no_lookahead() + test_transaction_costs_reduce_equity() + test_metrics_and_plot() + print("\nAll tests passed.")