diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..10366c5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,14 @@ +name: tests +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -e ".[dev]" + - run: python -m unittest discover -s tests -v + - run: ruff check engine tests run.py diff --git a/.gitignore b/.gitignore index 9269e3e..00d97d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ +.venv/ __pycache__/ -*.pyc +*.py[cod] +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +build/ +dist/ +data/*.csv data/*.png +!data/.gitkeep diff --git a/README.md b/README.md index 94b291c..8aa6e34 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,63 @@ -# backtest-engine +# Backtest Engine -An **event-driven backtesting engine, built from scratch** in Python — no -backtesting libraries doing the work. It mirrors the decoupled architecture real -trading systems use: components communicate only through events on a queue. +A compact event-driven backtesting engine built from scratch in Python. Its main research rule is explicit: **signals are generated at `close[t]`, filled at `open[t+1]`, and marked at `close[t+1]`**. That prevents the same-close look-ahead error common in first backtests. -## Architecture +## What version 1 includes - DataHandler --MarketEvent--> Strategy --SignalEvent--> Portfolio - ^ | - | OrderEvent - | v - +------------- FillEvent <--- ExecutionHandler <-------+ +- Injected pandas DataFrames for deterministic, network-free tests +- Target-weight sizing, cash reservation, and order rejection +- Directional slippage and per-share/minimum commissions +- Order, fill, trade, position, cash, rejection, and equity ledgers +- A cost-matched buy-and-hold benchmark +- Transaction-cost sensitivity at 0, 1, 5, and 10 bps +- Tests for timing, cash, multiple symbols, costs, exits, missing bars, and final marking -Each component is independent and swappable: +## Event flow -| Module | Responsibility | -|--------|----------------| -| `engine/data.py` | Streams historical bars, emits a `MarketEvent` per day | -| `engine/strategy.py` | Turns market data into `SignalEvent`s (example: MA crossover) | -| `engine/portfolio.py` | Tracks cash/positions/equity; sizes orders; books fills | -| `engine/execution.py` | Simulated broker with slippage + commission | -| `engine/backtest.py` | The event loop that drives everything | -| `engine/metrics.py` | Sharpe, drawdown, returns from the equity curve | +```text +bar opens -> pending orders fill -> bar closes -> strategy signals + ^ | + | v +next bar <--- order waits in broker <--- portfolio target weights +``` -## Run +## Install and test - pip install -r requirements.txt - python run.py # runs a 20/50 MA crossover on AAPL, saves the equity curve - python tests/test_portfolio.py # unit tests for portfolio accounting +```bash +python -m venv .venv +source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1 +pip install -e ".[dev]" +python -m unittest discover -s tests -v +``` -## Extending it +Run the SPY example (this step downloads market data): -Write a new strategy by subclassing `Strategy` and implementing -`calculate_signals(event)` — the rest of the engine is unchanged. That decoupling -is the whole point: the same engine can drive any strategy, and each component can -be tested in isolation. +```bash +python run.py +``` + +Outputs are saved under `data/`, including every ledger, the cost-sensitivity table, and an equity chart. + +## Minimal network-free use + +```python +import pandas as pd +from engine.backtest import Backtest +from engine.strategy import MovingAverageCross + +bars = pd.DataFrame( + {"Open": [100, 101, 102], "Close": [101, 102, 103]}, + index=pd.date_range("2024-01-01", periods=3), +) +results = Backtest( + ["TEST"], + price_data={"TEST": bars}, + strategy_cls=MovingAverageCross, + strategy_kwargs={"short": 1, "long": 2}, +).run() +print(results["fills"]) +``` + +## Scope and limitations + +This is an educational daily-bar simulator, not a production trading system. It does not model partial market liquidity, limit orders, corporate-action edge cases, borrow, taxes, or intraday queue position. See [methodology](docs/METHODOLOGY.md), [design](docs/DESIGN.md), and [results](docs/RESULTS.md). diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..3367b04 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,21 @@ +# Design + +The engine separates market data, strategy logic, portfolio accounting, and execution through typed queue events. This makes timing observable and lets each component be replaced independently. + +## Bar lifecycle + +For each timestamp, the data handler publishes a market event. The broker first fills orders that were pending from an earlier timestamp at the current open. The strategy then observes the current close and may publish target weights. The portfolio converts changed targets to orders, which remain pending until another market event. Finally, the portfolio marks cash and positions at the current close. + +An order submitted on the final bar is cancelled with `end_of_data`; it is never silently filled at the same close. + +## Accounting invariants + +- Long-only positions cannot fall below zero. +- New buy orders reserve estimated cash. +- Gaps beyond the reserve buffer cause a smaller fill or rejection, never negative cash. +- Equity equals cash plus each position valued at the current close. +- Every order ends as filled, partially filled, rejected, or cancelled. + +## Intentionally excluded + +Version 1 does not add more strategies. Its goal is a reliable engine boundary, auditable ledgers, and honest evaluation—not a strategy catalogue. diff --git a/docs/METHODOLOGY.md b/docs/METHODOLOGY.md new file mode 100644 index 0000000..b930059 --- /dev/null +++ b/docs/METHODOLOGY.md @@ -0,0 +1,17 @@ +# Methodology + +## Timing convention + +A strategy may use information through `close[t]`. Its order becomes eligible at `open[t+1]`. Portfolio equity is marked at `close[t+1]`. The test suite asserts the signal timestamp is earlier than the fill timestamp. + +## Costs + +Market orders receive adverse slippage: buys pay above the open and sells receive below it. Commissions are the greater of a minimum charge and a per-share charge. `run.py` repeats the experiment at 0, 1, 5, and 10 basis points. + +## Comparison + +The example compares the one included moving-average rule with buy-and-hold. Both start with identical capital and use the same entry commission/slippage assumptions. This is a baseline, not evidence of a profitable strategy. + +## Research discipline + +For strategy research beyond the example, split data chronologically into training, validation, and untouched test periods. Choose rules only with training/validation, report the untouched result once, and examine sensitivity across parameters, costs, and market regimes. Walk-forward evaluation should retrain only on information available at each historical date. diff --git a/docs/RESULTS.md b/docs/RESULTS.md new file mode 100644 index 0000000..132ba03 --- /dev/null +++ b/docs/RESULTS.md @@ -0,0 +1,14 @@ +# Results + +`python run.py` downloads SPY daily bars from 2015 through 2024, runs a 20/50-day moving-average rule, compares it with cost-matched buy-and-hold, and writes reproducible outputs to `data/`. + +Expected files: + +- `equity_curve.png` +- `cost_sensitivity.csv` +- `orders.csv`, `fills.csv`, `trades.csv` +- `positions.csv`, `cash.csv`, `equity.csv`, `rejections.csv` + +Numerical results are deliberately generated rather than hard-coded because the upstream adjusted dataset can change. Interpret the study as an engine demonstration. A simple moving-average result can depend heavily on the chosen period, parameters, market regime, and assumed costs; it is not investment advice or a claim of deployable alpha. + +The most important version-one result is structural: automated tests verify next-bar execution, event order, cost direction, cash constraints, multi-symbol reservations, exits, common-calendar handling, final marking, and end-of-data cancellation. diff --git a/engine/backtest.py b/engine/backtest.py index 72b473c..5317d88 100644 --- a/engine/backtest.py +++ b/engine/backtest.py @@ -1,35 +1,82 @@ -"""Orchestrates the event loop that drives the whole simulation.""" +"""Event-loop orchestration with explicit close-signal/next-open execution.""" + +from __future__ import annotations + import queue + +import pandas as pd + from .data import DataHandler -from .portfolio import Portfolio from .execution import SimulatedExecutionHandler +from .portfolio import Portfolio class Backtest: - def __init__(self, symbols, start, initial_capital, strategy_cls, end=None, **strat_kwargs): + def __init__( + self, + symbols, + start=None, + initial_capital=100_000.0, + strategy_cls=None, + end=None, + price_data=None, + commission_per_share=0.005, + minimum_commission=1.0, + slippage_bps=1.0, + strategy_kwargs=None, + **legacy_strategy_kwargs, + ): + if strategy_cls is None: + raise ValueError("strategy_cls is required") self.events = queue.Queue() - self.data = DataHandler(self.events, symbols, start, end) - self.strategy = strategy_cls(self.data, self.events, **strat_kwargs) + self.data = DataHandler(self.events, symbols, start, end, price_data=price_data) + kwargs = dict(strategy_kwargs or {}) + kwargs.update(legacy_strategy_kwargs) + self.strategy = strategy_cls(self.data, self.events, **kwargs) self.portfolio = Portfolio(self.data, self.events, initial_capital) - self.execution = SimulatedExecutionHandler(self.data, self.events) + self.execution = SimulatedExecutionHandler( + self.data, + self.events, + commission_per_share=commission_per_share, + minimum_commission=minimum_commission, + slippage_bps=slippage_bps, + ) def run(self): while self.data.continue_backtest: self.data.update_bars() if not self.data.continue_backtest: break + market_event = None while True: try: - ev = self.events.get(False) + event = self.events.get_nowait() except queue.Empty: break - if ev.type == "MARKET": - self.strategy.calculate_signals(ev) - self.portfolio.update_timeindex(ev) - elif ev.type == "SIGNAL": - self.portfolio.update_signal(ev) - elif ev.type == "ORDER": - self.execution.execute_order(ev) - elif ev.type == "FILL": - self.portfolio.update_fill(ev) - return self.portfolio.equity_curve + if event.type == "MARKET": + market_event = event + self.execution.on_market(event) + self.strategy.calculate_signals(event) + elif event.type == "SIGNAL": + self.portfolio.update_signal(event) + elif event.type == "ORDER": + self.execution.execute_order(event) + elif event.type == "FILL": + self.portfolio.update_fill(event) + if market_event is not None: + self.portfolio.mark_to_market(market_event) + + for order in self.execution.cancel_all(): + self.portfolio.cancel_order(order) + return self.results() + + def results(self): + return { + "equity": pd.DataFrame(self.portfolio.equity_curve), + "orders": pd.DataFrame(self.portfolio.orders), + "fills": pd.DataFrame(self.portfolio.fills), + "trades": pd.DataFrame(self.portfolio.trades), + "positions": pd.DataFrame(self.portfolio.positions_ledger), + "cash": pd.DataFrame(self.portfolio.cash_ledger), + "rejections": pd.DataFrame(self.portfolio.rejections), + } diff --git a/engine/data.py b/engine/data.py index 8f4ec54..26609df 100644 --- a/engine/data.py +++ b/engine/data.py @@ -1,44 +1,108 @@ -"""Streams historical bars and emits a MarketEvent per trading day.""" +"""Historical bar data with network-free DataFrame injection for tests.""" + +from __future__ import annotations + +from collections.abc import Mapping +from queue import Queue + import numpy as np import pandas as pd -import yfinance as yf + from .events import MarketEvent +def download_price_data(symbols, start, end=None) -> dict[str, pd.DataFrame]: + """Download adjusted OHLC bars. Imported lazily so tests need no network.""" + import yfinance as yf + + output: dict[str, pd.DataFrame] = {} + for symbol in symbols: + raw = yf.download( + symbol, + start=start, + end=end, + auto_adjust=True, + progress=False, + ) + if raw.empty: + raise ValueError(f"no price data returned for {symbol}") + if isinstance(raw.columns, pd.MultiIndex): + raw.columns = raw.columns.get_level_values(0) + output[symbol] = raw[["Open", "Close"]].copy() + return output + + class DataHandler: - def __init__(self, events, symbols, start, end=None): + """Streams a common calendar of Open/Close bars one timestamp at a time.""" + + def __init__( + self, + events: Queue, + symbols, + start=None, + end=None, + price_data: Mapping[str, pd.DataFrame] | None = None, + ): self.events = events - self.symbols = symbols - series = {} - for s in symbols: - df = yf.download(s, start=start, end=end, auto_adjust=True, progress=False) - if isinstance(df.columns, pd.MultiIndex): - c = df["Close"] - close = c[s] if s in c.columns else c.iloc[:, 0] - else: - close = df["Close"] - series[s] = close.astype(float).dropna() - idx = None - for s in symbols: - idx = series[s].index if idx is None else idx.intersection(series[s].index) - idx = idx.sort_values() - self.dates = list(idx) - self.closes = {s: series[s].reindex(idx).values for s in symbols} + self.symbols = list(symbols) + if not self.symbols: + raise ValueError("at least one symbol is required") + + source = ( + dict(price_data) + if price_data is not None + else download_price_data(self.symbols, start, end) + ) + missing = set(self.symbols).difference(source) + if missing: + raise ValueError(f"missing price data for: {sorted(missing)}") + + self.frames = {symbol: self._normalize(source[symbol], symbol) for symbol in self.symbols} + common = self.frames[self.symbols[0]].index + for symbol in self.symbols[1:]: + common = common.intersection(self.frames[symbol].index) + common = common.sort_values() + if len(common) < 2: + raise ValueError("symbols need at least two common bars") + + self.frames = {symbol: frame.loc[common].copy() for symbol, frame in self.frames.items()} + self.dates = list(common) self.i = -1 self.current_dt = None self.continue_backtest = True - def update_bars(self): + @staticmethod + def _normalize(frame: pd.DataFrame, symbol: str) -> pd.DataFrame: + if not isinstance(frame, pd.DataFrame): + raise TypeError(f"price_data[{symbol!r}] must be a DataFrame") + renamed = frame.rename(columns={str(c).lower(): str(c).title() for c in frame.columns}) + if not {"Open", "Close"}.issubset(renamed.columns): + raise ValueError(f"{symbol} requires Open and Close columns") + out = renamed[["Open", "Close"]].copy() + out.index = pd.DatetimeIndex(pd.to_datetime(out.index)).tz_localize(None) + out = out[~out.index.duplicated(keep="last")].sort_index().astype(float).dropna() + if (out <= 0).any().any(): + raise ValueError(f"{symbol} contains non-positive prices") + return out + + def update_bars(self) -> None: self.i += 1 if self.i >= len(self.dates): self.continue_backtest = False return self.current_dt = self.dates[self.i] - self.events.put(MarketEvent()) + self.events.put(MarketEvent(self.current_dt.to_pydatetime())) - def get_latest_close(self, symbol): - return float(self.closes[symbol][self.i]) + def get_latest_open(self, symbol: str) -> float: + return float(self.frames[symbol].iloc[self.i]["Open"]) - def get_latest_closes(self, symbol, n): + def get_latest_close(self, symbol: str) -> float: + return float(self.frames[symbol].iloc[self.i]["Close"]) + + def get_latest_closes(self, symbol: str, n: int) -> np.ndarray: lo = max(0, self.i - n + 1) - return np.asarray(self.closes[symbol][lo:self.i + 1], dtype=float) + return self.frames[symbol]["Close"].iloc[lo : self.i + 1].to_numpy(dtype=float) + + def frame(self, symbol: str) -> pd.DataFrame: + return self.frames[symbol].copy() + diff --git a/engine/events.py b/engine/events.py index 56bb1e8..fc9c809 100644 --- a/engine/events.py +++ b/engine/events.py @@ -1,34 +1,47 @@ -"""Event types that flow through the backtest queue.""" +"""Typed events passed through the backtest queue.""" +from __future__ import annotations +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass(frozen=True) class MarketEvent: - def __init__(self): - self.type = "MARKET" + dt: datetime + type: str = field(default="MARKET", init=False) +@dataclass(frozen=True) class SignalEvent: - def __init__(self, symbol, dt, direction): - self.type = "SIGNAL" - self.symbol = symbol - self.dt = dt - self.direction = direction # LONG, EXIT (SHORT reserved) + symbol: str + dt: datetime + target_weight: float + type: str = field(default="SIGNAL", init=False) +@dataclass(frozen=True) class OrderEvent: - def __init__(self, symbol, quantity, direction, order_type="MKT"): - self.type = "ORDER" - self.symbol = symbol - self.quantity = int(quantity) - self.direction = direction # BUY / SELL - self.order_type = order_type + order_id: str + symbol: str + quantity: int + direction: str + submitted_dt: datetime + reference_price: float + type: str = field(default="ORDER", init=False) +@dataclass(frozen=True) class FillEvent: - def __init__(self, dt, symbol, quantity, direction, fill_price, commission): - self.type = "FILL" - self.dt = dt - self.symbol = symbol - self.quantity = int(quantity) - self.direction = direction - self.fill_price = float(fill_price) - self.commission = float(commission) + order_id: str + submitted_dt: datetime + dt: datetime + symbol: str + quantity: int + direction: str + reference_price: float + fill_price: float + commission: float + slippage_cost: float + type: str = field(default="FILL", init=False) + diff --git a/engine/execution.py b/engine/execution.py index f24fd14..6db2b39 100644 --- a/engine/execution.py +++ b/engine/execution.py @@ -1,18 +1,51 @@ -"""Simulated broker: fills orders at close with slippage + commission.""" +"""A deterministic simulated broker with next-open fills.""" + +from __future__ import annotations + from .events import FillEvent class SimulatedExecutionHandler: - def __init__(self, data, events, commission_per_share=0.005, slippage_bps=1.0): + def __init__( + self, + data, + events, + commission_per_share=0.005, + minimum_commission=1.0, + slippage_bps=1.0, + ): self.data = data self.events = events - self.commission_per_share = commission_per_share - self.slippage_bps = slippage_bps + self.commission_per_share = float(commission_per_share) + self.minimum_commission = float(minimum_commission) + self.slippage_bps = float(slippage_bps) + self.pending_orders = [] + + def execute_order(self, event) -> None: + self.pending_orders.append(event) + + def on_market(self, event) -> None: + pending, self.pending_orders = self.pending_orders, [] + for order in pending: + open_price = self.data.get_latest_open(order.symbol) + sign = 1.0 if order.direction == "BUY" else -1.0 + fill_price = open_price * (1.0 + sign * self.slippage_bps / 10_000.0) + commission = max(self.minimum_commission, order.quantity * self.commission_per_share) + self.events.put( + FillEvent( + order_id=order.order_id, + submitted_dt=order.submitted_dt, + dt=event.dt, + symbol=order.symbol, + quantity=order.quantity, + direction=order.direction, + reference_price=open_price, + fill_price=fill_price, + commission=commission, + slippage_cost=abs(fill_price - open_price) * order.quantity, + ) + ) - def execute_order(self, event): - s = event.symbol - price = self.data.get_latest_close(s) - sign = 1.0 if event.direction == "BUY" else -1.0 - fill_price = price * (1.0 + sign * self.slippage_bps / 10000.0) - commission = max(1.0, event.quantity * self.commission_per_share) - self.events.put(FillEvent(self.data.current_dt, s, event.quantity, event.direction, fill_price, commission)) + def cancel_all(self): + pending, self.pending_orders = self.pending_orders, [] + return pending diff --git a/engine/metrics.py b/engine/metrics.py index c02d7d5..2e3db9e 100644 --- a/engine/metrics.py +++ b/engine/metrics.py @@ -1,16 +1,41 @@ -"""Performance statistics from the equity curve.""" +"""Performance statistics and a cost-matched buy-and-hold benchmark.""" + +from __future__ import annotations + import numpy as np import pandas as pd -def performance(equity_curve, initial_capital): - df = pd.DataFrame(equity_curve, columns=["dt", "equity"]).set_index("dt") - df["returns"] = df["equity"].pct_change().fillna(0.0) - days = max(len(df), 1) - final = df["equity"].iloc[-1] +def performance(equity, initial_capital): + frame = equity.copy() if isinstance(equity, pd.DataFrame) else pd.DataFrame(equity) + if frame.empty: + raise ValueError("equity curve is empty") + frame = frame.set_index("dt") if "dt" in frame.columns else frame + frame["returns"] = frame["equity"].pct_change().fillna(0.0) + final = float(frame["equity"].iloc[-1]) total = final / initial_capital - 1.0 - ann = (final / initial_capital) ** (252.0 / days) - 1.0 - sharpe = np.sqrt(252) * df["returns"].mean() / df["returns"].std() if df["returns"].std() > 0 else 0.0 - cummax = df["equity"].cummax() - maxdd = (df["equity"] / cummax - 1.0).min() - return {"final_equity": final, "total_return": total, "annual_return": ann, "sharpe": sharpe, "max_drawdown": maxdd}, df + periods = max(len(frame) - 1, 1) + annual = (final / initial_capital) ** (252.0 / periods) - 1.0 + volatility = frame["returns"].std(ddof=0) + sharpe = np.sqrt(252.0) * frame["returns"].mean() / volatility if volatility > 0 else 0.0 + drawdown = frame["equity"] / frame["equity"].cummax() - 1.0 + return { + "final_equity": final, + "total_return": total, + "annual_return": annual, + "sharpe": float(sharpe), + "max_drawdown": float(drawdown.min()), + }, frame + + +def buy_and_hold(frame, initial_capital, commission_per_share=0.005, minimum_commission=1.0, slippage_bps=1.0): + """Buy at the first open and mark at every close using the same entry costs.""" + bars = frame.copy() + entry = float(bars["Open"].iloc[0]) * (1.0 + slippage_bps / 10_000.0) + quantity = int((initial_capital - minimum_commission) // entry) + commission = max(minimum_commission, quantity * commission_per_share) + while quantity > 0 and quantity * entry + commission > initial_capital: + quantity -= 1 + commission = max(minimum_commission, quantity * commission_per_share) + cash = initial_capital - quantity * entry - commission + return pd.DataFrame({"dt": bars.index, "equity": cash + quantity * bars["Close"].to_numpy()}) diff --git a/engine/portfolio.py b/engine/portfolio.py index a117e5f..f72e65e 100644 --- a/engine/portfolio.py +++ b/engine/portfolio.py @@ -1,40 +1,174 @@ -"""Tracks cash, positions, and equity; turns signals into orders and fills into P&L.""" +"""Portfolio accounting, target-weight sizing, reservations, and ledgers.""" + +from __future__ import annotations + from .events import OrderEvent class Portfolio: - def __init__(self, data, events, initial_capital=100000.0): + def __init__(self, data, events, initial_capital=100_000.0, reserve_buffer=0.02): self.data = data self.events = events self.symbols = data.symbols self.initial_capital = float(initial_capital) self.cash = float(initial_capital) - self.positions = {s: 0 for s in self.symbols} + self.positions = {symbol: 0 for symbol in self.symbols} + self.reserve_buffer = float(reserve_buffer) + self.reserved_cash = 0.0 + self._reservations = {} + self._next_order_id = 1 + + self.orders = [] + self.fills = [] + self.trades = [] + self.positions_ledger = [] + self.cash_ledger = [] self.equity_curve = [] + self.rejections = [] + + def _equity(self) -> float: + market_value = sum( + self.positions[symbol] * self.data.get_latest_close(symbol) + for symbol in self.symbols + ) + return self.cash + market_value - def update_timeindex(self, event): - mv = sum(self.positions[s] * self.data.get_latest_close(s) for s in self.symbols) - self.equity_curve.append((self.data.current_dt, self.cash + mv)) + def update_signal(self, event) -> None: + symbol = event.symbol + price = self.data.get_latest_close(symbol) + equity = self._equity() + target_quantity = int((equity * event.target_weight) // price) + quantity_delta = target_quantity - self.positions[symbol] + if quantity_delta == 0: + return + + direction = "BUY" if quantity_delta > 0 else "SELL" + requested = abs(quantity_delta) + quantity = requested + if direction == "SELL": + quantity = min(quantity, self.positions[symbol]) + else: + available = max(0.0, self.cash - self.reserved_cash) + estimated_unit_cost = price * (1.0 + self.reserve_buffer) + quantity = min(quantity, int(max(0.0, available - 1.0) // estimated_unit_cost)) - def update_signal(self, event): - s = event.symbol - price = self.data.get_latest_close(s) - if price <= 0: + if quantity <= 0: + self._reject(None, symbol, requested, "insufficient_cash") return - if event.direction == "LONG": - qty = int((self.cash * 0.95) // price) - if qty > 0: - self.events.put(OrderEvent(s, qty, "BUY")) - elif event.direction in ("EXIT", "SHORT"): - qty = self.positions[s] - if qty > 0: - self.events.put(OrderEvent(s, qty, "SELL")) - - def update_fill(self, event): - s = event.symbol + if quantity < requested: + self._reject(None, symbol, requested - quantity, "partially_sized_for_cash") + + order_id = f"O{self._next_order_id:05d}" + self._next_order_id += 1 + if direction == "BUY": + reservation = quantity * price * (1.0 + self.reserve_buffer) + 1.0 + self._reservations[order_id] = reservation + self.reserved_cash += reservation + + order = OrderEvent( + order_id=order_id, + symbol=symbol, + quantity=quantity, + direction=direction, + submitted_dt=event.dt, + reference_price=price, + ) + self.orders.append( + { + "order_id": order_id, + "submitted_dt": event.dt, + "symbol": symbol, + "direction": direction, + "quantity": quantity, + "reference_price": price, + "status": "pending", + } + ) + self.events.put(order) + + def update_fill(self, event) -> None: + reservation = self._reservations.pop(event.order_id, 0.0) + self.reserved_cash = max(0.0, self.reserved_cash - reservation) + quantity = event.quantity + if event.direction == "BUY": - self.positions[s] += event.quantity - self.cash -= event.quantity * event.fill_price + event.commission + affordable = int(max(0.0, self.cash - event.commission) // event.fill_price) + if affordable < quantity: + self._reject(event.order_id, event.symbol, quantity - affordable, "gap_exceeded_cash") + quantity = affordable + if quantity <= 0: + self._set_order_status(event.order_id, "rejected") + return + commission = event.commission * quantity / event.quantity + self.positions[event.symbol] += quantity + self.cash -= quantity * event.fill_price + commission + cash_flow = -(quantity * event.fill_price + commission) else: - self.positions[s] -= event.quantity - self.cash += event.quantity * event.fill_price - event.commission + quantity = min(quantity, self.positions[event.symbol]) + if quantity <= 0: + self._reject(event.order_id, event.symbol, event.quantity, "no_position_to_sell") + self._set_order_status(event.order_id, "rejected") + return + commission = event.commission * quantity / event.quantity + self.positions[event.symbol] -= quantity + self.cash += quantity * event.fill_price - commission + cash_flow = quantity * event.fill_price - commission + + slippage_cost = abs(event.fill_price - event.reference_price) * quantity + record = { + "order_id": event.order_id, + "submitted_dt": event.submitted_dt, + "fill_dt": event.dt, + "symbol": event.symbol, + "direction": event.direction, + "quantity": quantity, + "reference_price": event.reference_price, + "fill_price": event.fill_price, + "commission": commission, + "slippage_cost": slippage_cost, + "cash_flow": cash_flow, + } + self.fills.append(record) + self.trades.append(record.copy()) + self._set_order_status(event.order_id, "filled" if quantity == event.quantity else "partially_filled") + + def mark_to_market(self, event) -> None: + equity = self._equity() + self.equity_curve.append({"dt": event.dt, "equity": equity}) + self.cash_ledger.append( + {"dt": event.dt, "cash": self.cash, "reserved_cash": self.reserved_cash, "equity": equity} + ) + for symbol in self.symbols: + close = self.data.get_latest_close(symbol) + self.positions_ledger.append( + { + "dt": event.dt, + "symbol": symbol, + "quantity": self.positions[symbol], + "close": close, + "market_value": self.positions[symbol] * close, + } + ) + + def cancel_order(self, order, reason="end_of_data") -> None: + reservation = self._reservations.pop(order.order_id, 0.0) + self.reserved_cash = max(0.0, self.reserved_cash - reservation) + self._set_order_status(order.order_id, "cancelled") + self._reject(order.order_id, order.symbol, order.quantity, reason) + + def _set_order_status(self, order_id, status) -> None: + for order in self.orders: + if order["order_id"] == order_id: + order["status"] = status + return + + def _reject(self, order_id, symbol, quantity, reason) -> None: + self.rejections.append( + { + "dt": self.data.current_dt, + "order_id": order_id, + "symbol": symbol, + "quantity": quantity, + "reason": reason, + } + ) diff --git a/engine/strategy.py b/engine/strategy.py index 94a4332..bdc97c9 100644 --- a/engine/strategy.py +++ b/engine/strategy.py @@ -1,33 +1,40 @@ -"""Strategy interface + a moving-average crossover example.""" +"""Strategy interface and one deliberately simple moving-average example.""" + +from __future__ import annotations + from .events import SignalEvent class Strategy: - def calculate_signals(self, event): + def calculate_signals(self, event) -> None: raise NotImplementedError class MovingAverageCross(Strategy): - def __init__(self, data, events, short=20, long=50): + """Emit target weights at the close; fills occur on the next bar.""" + + def __init__(self, data, events, short=20, long=50, gross_allocation=0.90): + if not 0 < short < long: + raise ValueError("require 0 < short < long") + if not 0 < gross_allocation <= 1: + raise ValueError("gross_allocation must be in (0, 1]") self.data = data self.events = events self.short = short self.long = long - self.invested = {s: False for s in data.symbols} + self.weight_when_long = gross_allocation / len(data.symbols) + self.targets = {symbol: 0.0 for symbol in data.symbols} - def calculate_signals(self, event): + def calculate_signals(self, event) -> None: if event.type != "MARKET": return - for s in self.data.symbols: - closes = self.data.get_latest_closes(s, self.long) + for symbol in self.data.symbols: + closes = self.data.get_latest_closes(symbol, self.long) if len(closes) < self.long: continue - sma_s = closes[-self.short:].mean() - sma_l = closes.mean() - dt = self.data.current_dt - if sma_s > sma_l and not self.invested[s]: - self.events.put(SignalEvent(s, dt, "LONG")) - self.invested[s] = True - elif sma_s < sma_l and self.invested[s]: - self.events.put(SignalEvent(s, dt, "EXIT")) - self.invested[s] = False + short_average = closes[-self.short :].mean() + long_average = closes.mean() + target = self.weight_when_long if short_average > long_average else 0.0 + if target != self.targets[symbol]: + self.events.put(SignalEvent(symbol, event.dt, target)) + self.targets[symbol] = target diff --git a/nl_backtest.py b/nl_backtest.py new file mode 100644 index 0000000..dc64bb8 --- /dev/null +++ b/nl_backtest.py @@ -0,0 +1,77 @@ +""" +Natural-language backtesting: describe a strategy in English, an LLM converts it +into validated parameters, and the engine runs a real backtest. + +Design note: the model does NOT write or run code. It emits STRUCTURED params +(chosen from a whitelist of strategies), which we validate and map to real +Strategy classes. Safe by construction. + + export GEMINI_API_KEY=... + python nl_backtest.py "buy AAPL when the 10-day average crosses above the 30-day, sell on the reverse" +""" +import os +import sys +import json +import urllib.request +from engine.backtest import Backtest +from engine.strategy import MovingAverageCross, RSIMeanReversion +from engine.metrics import performance + +STRATEGIES = {"ma_cross": MovingAverageCross, "rsi": RSIMeanReversion} + + +def parse_idea(description): + key = os.environ.get("GEMINI_API_KEY") + if not key: + raise SystemExit("Set GEMINI_API_KEY first (export GEMINI_API_KEY=...).") + schema = { + "type": "OBJECT", + "properties": { + "strategy": {"type": "STRING", "enum": ["ma_cross", "rsi"]}, + "symbol": {"type": "STRING"}, + "short": {"type": "NUMBER"}, "long": {"type": "NUMBER"}, + "period": {"type": "NUMBER"}, "oversold": {"type": "NUMBER"}, "overbought": {"type": "NUMBER"}, + }, + "required": ["strategy", "symbol"], + } + body = { + "systemInstruction": {"parts": [{"text": + "Convert a plain-English trading idea into structured backtest parameters. " + "Use strategy 'ma_cross' for moving-average crossovers or 'rsi' for RSI mean-reversion. " + "Fill only the relevant numeric fields and pick sensible defaults if unspecified. " + "Extract the ticker as symbol in uppercase."}]}, + "contents": [{"role": "user", "parts": [{"text": description}]}], + "generationConfig": {"responseMimeType": "application/json", "responseSchema": schema, "thinkingConfig": {"thinkingBudget": 0}}, + } + url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key=" + key + req = urllib.request.Request(url, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}) + resp = urllib.request.urlopen(req, timeout=30) + text = json.loads(resp.read())["candidates"][0]["content"]["parts"][0]["text"] + return json.loads(text) + + +def main(): + desc = " ".join(sys.argv[1:]) or "buy AAPL when the 20-day average crosses above the 50-day, sell on the reverse" + print("Idea: " + desc) + spec = parse_idea(desc) + print("AI parsed -> " + json.dumps(spec)) + + strat = spec.get("strategy", "ma_cross") + symbol = str(spec.get("symbol", "AAPL")).upper() + cls = STRATEGIES.get(strat, MovingAverageCross) + if strat == "rsi": + kwargs = {"period": int(spec.get("period") or 14), "oversold": float(spec.get("oversold") or 30), "overbought": float(spec.get("overbought") or 70)} + else: + kwargs = {"short": int(spec.get("short") or 20), "long": int(spec.get("long") or 50)} + + bt = Backtest([symbol], "2018-01-01", 100000.0, cls, **kwargs) + stats, _ = performance(bt.run(), 100000.0) + print("-" * 50) + print("Backtest: %s on %s %s" % (strat, symbol, json.dumps(kwargs))) + print(" Total return: %+.1f%%" % (stats["total_return"] * 100)) + print(" Sharpe: %.2f" % stats["sharpe"]) + print(" Max drawdown: %.1f%%" % (stats["max_drawdown"] * 100)) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2c1f310 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "backtest-engine-asoracca" +version = "1.0.0" +description = "A small event-driven backtesting engine with next-bar execution and auditable ledgers" +requires-python = ">=3.10" +dependencies = ["numpy>=1.26", "pandas>=2.1", "yfinance>=0.2.40", "matplotlib>=3.8"] + +[project.optional-dependencies] +dev = ["pytest>=8", "ruff>=0.5"] + +[tool.setuptools.packages.find] +include = ["engine*"] + +[tool.ruff] +line-length = 110 diff --git a/requirements.txt b/requirements.txt index dc1061e..d782aaa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -yfinance>=0.2 -pandas>=2.0 -numpy>=1.24 -matplotlib>=3.7 +numpy>=1.26 +pandas>=2.1 +yfinance>=0.2.40 +matplotlib>=3.8 diff --git a/run.py b/run.py index 9b17f0a..f203d0d 100644 --- a/run.py +++ b/run.py @@ -1,31 +1,51 @@ -"""Example: run a 20/50 moving-average crossover through the engine.""" -import os -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt +"""Reproducible example, benchmark, and transaction-cost sensitivity study.""" + +from pathlib import Path + +import pandas as pd + from engine.backtest import Backtest +from engine.data import download_price_data +from engine.metrics import buy_and_hold, performance from engine.strategy import MovingAverageCross -from engine.metrics import performance def main(): - symbols, cap = ["AAPL"], 100000.0 - bt = Backtest(symbols, "2018-01-01", cap, MovingAverageCross, short=20, long=50) - equity = bt.run() - stats, df = performance(equity, cap) - print("Moving-average crossover (20/50) on %s" % symbols[0]) - print(" Final equity: $%s" % format(stats["final_equity"], ",.0f")) - print(" Total return: %+.1f%%" % (stats["total_return"] * 100)) - print(" Annual return: %+.1f%%" % (stats["annual_return"] * 100)) - print(" Sharpe: %.2f" % stats["sharpe"]) - print(" Max drawdown: %.1f%%" % (stats["max_drawdown"] * 100)) - plt.figure(figsize=(10, 5)) - plt.plot(df.index, df["equity"]) - plt.title("Equity curve — MA(20/50) on %s" % symbols[0]) - plt.grid(alpha=0.3); plt.tight_layout() - os.makedirs("data", exist_ok=True) - plt.savefig("data/equity_curve.png", dpi=110) - print(" Saved: data/equity_curve.png") + symbol, capital = "SPY", 100_000.0 + price_data = download_price_data([symbol], "2015-01-01", "2025-01-01") + output = Path("data") + output.mkdir(exist_ok=True) + + curves = {} + sensitivity = [] + for bps in (0, 1, 5, 10): + result = Backtest( + [symbol], + price_data=price_data, + initial_capital=capital, + strategy_cls=MovingAverageCross, + strategy_kwargs={"short": 20, "long": 50, "gross_allocation": .90}, + slippage_bps=bps, + ).run() + stats, curve = performance(result["equity"], capital) + curves[f"MA 20/50 ({bps} bps)"] = curve["equity"] + sensitivity.append({"slippage_bps": bps, **stats, "fills": len(result["fills"])}) + if bps == 1: + for name, ledger in result.items(): + ledger.to_csv(output / f"{name}.csv", index=False) + + benchmark = buy_and_hold(price_data[symbol], capital, slippage_bps=1) + benchmark_stats, benchmark_curve = performance(benchmark, capital) + curves["Buy & hold (1 bps)"] = benchmark_curve["equity"] + pd.DataFrame(sensitivity).to_csv(output / "cost_sensitivity.csv", index=False) + + ax = pd.DataFrame(curves).plot(figsize=(11, 6), title="SPY: MA(20/50) vs cost-matched buy & hold") + ax.set_ylabel("Portfolio value ($)") + ax.figure.tight_layout() + ax.figure.savefig(output / "equity_curve.png", dpi=150) + print(pd.DataFrame(sensitivity).to_string(index=False)) + print("\nBuy & hold (1 bps):", benchmark_stats) + print("\nSaved ledgers, sensitivity results, and chart to data/") if __name__ == "__main__": diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..83e8cb0 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,116 @@ +import queue +import unittest + +import pandas as pd + +from engine.backtest import Backtest +from engine.data import DataHandler +from engine.events import SignalEvent +from engine.execution import SimulatedExecutionHandler +from engine.strategy import Strategy + + +def bars(opens, closes, start="2024-01-01"): + index = pd.date_range(start, periods=len(opens), freq="D") + return pd.DataFrame({"Open": opens, "Close": closes}, index=index) + + +class BuyThenExit(Strategy): + def __init__(self, data, events, buy_bar=0, exit_bar=None, weight=0.9): + self.data, self.events = data, events + self.buy_bar, self.exit_bar, self.weight = buy_bar, exit_bar, weight + + def calculate_signals(self, event): + if self.data.i == self.buy_bar: + for symbol in self.data.symbols: + self.events.put(SignalEvent(symbol, event.dt, self.weight / len(self.data.symbols))) + if self.exit_bar is not None and self.data.i == self.exit_bar: + for symbol in self.data.symbols: + self.events.put(SignalEvent(symbol, event.dt, 0.0)) + + +class EngineTests(unittest.TestCase): + def run_bt(self, price_data, **kwargs): + return Backtest( + symbols=list(price_data), + price_data=price_data, + initial_capital=kwargs.pop("initial_capital", 1_000.0), + strategy_cls=BuyThenExit, + strategy_kwargs=kwargs.pop("strategy_kwargs", {}), + **kwargs, + ).run() + + def test_signal_at_close_fills_next_open(self): + results = self.run_bt({"X": bars([10, 20, 30], [12, 21, 31])}, slippage_bps=0) + fill = results["fills"].iloc[0] + self.assertLess(fill.submitted_dt, fill.fill_dt) + self.assertEqual(fill.fill_price, 20) + + def test_event_order_fill_precedes_same_bar_exit_signal(self): + results = self.run_bt( + {"X": bars([10, 20, 30, 40], [12, 21, 31, 41])}, + slippage_bps=0, + strategy_kwargs={"exit_bar": 1}, + ) + self.assertEqual(list(results["fills"]["direction"]), ["BUY", "SELL"]) + self.assertEqual(list(results["fills"]["fill_price"]), [20, 30]) + + def test_insufficient_cash_never_goes_negative(self): + results = self.run_bt( + {"X": bars([1, 1_000, 1_000], [1, 1_000, 1_000])}, + initial_capital=100, + slippage_bps=0, + ) + self.assertGreaterEqual(results["cash"]["cash"].min(), 0) + self.assertIn("gap_exceeded_cash", set(results["rejections"]["reason"])) + + def test_multiple_symbols_reserve_cash(self): + data = { + "A": bars([10, 10, 10], [10, 10, 10]), + "B": bars([20, 20, 20], [20, 20, 20]), + } + results = self.run_bt(data, slippage_bps=0) + self.assertGreaterEqual(results["cash"]["cash"].min(), 0) + self.assertEqual(set(results["fills"]["symbol"]), {"A", "B"}) + + def test_commission_and_directional_slippage(self): + events = queue.Queue() + data = DataHandler(events, ["X"], price_data={"X": bars([100, 100], [100, 100])}) + broker = SimulatedExecutionHandler(data, events, commission_per_share=.01, minimum_commission=1, slippage_bps=10) + data.update_bars() + events.get() + from engine.events import OrderEvent + buy = OrderEvent("O1", "X", 200, "BUY", data.current_dt, 100) + broker.execute_order(buy) + data.update_bars() + market = events.get() + broker.on_market(market) + fill = events.get() + self.assertAlmostEqual(fill.fill_price, 100.1) + self.assertEqual(fill.commission, 2.0) + + def test_missing_bars_use_common_calendar(self): + a = bars([10, 11, 12], [10, 11, 12]) + b = bars([20, 22], [20, 22], start="2024-01-02") + handler = DataHandler(queue.Queue(), ["A", "B"], price_data={"A": a, "B": b}) + self.assertEqual(len(handler.dates), 2) + + def test_final_mark_and_ledgers(self): + results = self.run_bt({"X": bars([10, 10, 10], [10, 11, 12])}, slippage_bps=0) + final_cash = results["cash"].iloc[-1]["cash"] + final_position = results["positions"].iloc[-1]["quantity"] + self.assertAlmostEqual(results["equity"].iloc[-1]["equity"], final_cash + final_position * 12) + for ledger in ("orders", "fills", "positions", "cash", "equity", "trades"): + self.assertFalse(results[ledger].empty, ledger) + + def test_last_bar_order_is_cancelled(self): + results = self.run_bt( + {"X": bars([10, 10], [10, 10])}, + strategy_kwargs={"buy_bar": 1}, + ) + self.assertEqual(results["orders"].iloc[0]["status"], "cancelled") + self.assertIn("end_of_data", set(results["rejections"]["reason"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py deleted file mode 100644 index 18ca666..0000000 --- a/tests/test_portfolio.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Unit tests for portfolio accounting — run: python tests/test_portfolio.py""" -import sys, os -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import queue -from engine.events import FillEvent -from engine.portfolio import Portfolio - - -class StubData: - symbols = ["X"] - current_dt = "2020-01-01" - - def get_latest_close(self, s): - return 100.0 - - -def test_accounting(): - p = Portfolio(StubData(), queue.Queue(), initial_capital=100000.0) - p.update_fill(FillEvent("2020-01-01", "X", 10, "BUY", 100.0, 1.0)) - assert p.positions["X"] == 10 - assert abs(p.cash - (100000 - 1000 - 1)) < 1e-6 - p.update_timeindex(None) - _, eq = p.equity_curve[-1] - assert abs(eq - (p.cash + 10 * 100)) < 1e-6 - p.update_fill(FillEvent("2020-01-02", "X", 10, "SELL", 100.0, 1.0)) - assert p.positions["X"] == 0 - assert abs(p.cash - (100000 - 2)) < 1e-6 - print("all portfolio tests passed") - - -if __name__ == "__main__": - test_accounting()