Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backtester/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__/
.cache/
*.png

# Forward-tester runtime state (per-machine, not source)
state.json
log.csv
115 changes: 115 additions & 0 deletions backtester/FORWARD_TEST.md
Original file line number Diff line number Diff line change
@@ -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.
107 changes: 107 additions & 0 deletions backtester/README.md
Original file line number Diff line number Diff line change
@@ -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.
163 changes: 163 additions & 0 deletions backtester/broker_paper.py
Original file line number Diff line number Diff line change
@@ -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
Loading