From 187ebe9a9e79f97299db5719692b46e88ab5e653 Mon Sep 17 00:00:00 2001 From: Brent Neale Date: Mon, 16 Feb 2026 15:58:00 +1000 Subject: [PATCH] Add backtesting engine with SMA cross strategy Reads features from Supabase, simulates SMA(3)/SMA(20) crossover signals, and outputs P&L, drawdown, and trade logs per instrument/granularity. Co-Authored-By: Claude Opus 4.6 --- logs/backtest_trades_EUR_USD_M1.csv | 19 ++ logs/backtest_trades_EUR_USD_M5.csv | 15 ++ logs/backtest_trades_USD_JPY_M1.csv | 11 + logs/backtest_trades_USD_JPY_M5.csv | 24 ++ src/backtester.py | 343 ++++++++++++++++++++++++++++ 5 files changed, 412 insertions(+) create mode 100644 logs/backtest_trades_EUR_USD_M1.csv create mode 100644 logs/backtest_trades_EUR_USD_M5.csv create mode 100644 logs/backtest_trades_USD_JPY_M1.csv create mode 100644 logs/backtest_trades_USD_JPY_M5.csv create mode 100644 src/backtester.py diff --git a/logs/backtest_trades_EUR_USD_M1.csv b/logs/backtest_trades_EUR_USD_M1.csv new file mode 100644 index 0000000..ed6b236 --- /dev/null +++ b/logs/backtest_trades_EUR_USD_M1.csv @@ -0,0 +1,19 @@ +time,instrument,granularity,side,price,position_size,equity,drawdown +2026-02-16 02:52:00+00:00,EUR_USD,M1,BUY,1.18646,100.0,10000.0,0.0 +2026-02-16 03:03:00+00:00,EUR_USD,M1,FLAT,1.18654,0.0,10000.01,2e-06 +2026-02-16 03:13:00+00:00,EUR_USD,M1,BUY,1.18672,100.0,10000.01,2e-06 +2026-02-16 03:30:00+00:00,EUR_USD,M1,FLAT,1.18659,0.0,10000.0,3e-06 +2026-02-16 03:44:00+00:00,EUR_USD,M1,BUY,1.18676,100.0,10000.0,3e-06 +2026-02-16 03:47:00+00:00,EUR_USD,M1,FLAT,1.18666,0.0,9999.99,3e-06 +2026-02-16 03:55:00+00:00,EUR_USD,M1,BUY,1.18672,100.0,9999.99,3e-06 +2026-02-16 04:07:00+00:00,EUR_USD,M1,FLAT,1.18664,0.0,9999.98,4e-06 +2026-02-16 04:10:00+00:00,EUR_USD,M1,BUY,1.18674,100.0,9999.98,4e-06 +2026-02-16 04:15:00+00:00,EUR_USD,M1,FLAT,1.18666,0.0,9999.97,5e-06 +2026-02-16 04:27:00+00:00,EUR_USD,M1,BUY,1.18672,100.0,9999.97,5e-06 +2026-02-16 04:31:00+00:00,EUR_USD,M1,FLAT,1.18661,0.0,9999.96,6e-06 +2026-02-16 05:01:00+00:00,EUR_USD,M1,BUY,1.18627,100.0,9999.96,6e-06 +2026-02-16 05:02:00+00:00,EUR_USD,M1,FLAT,1.18619,0.0,9999.96,6e-06 +2026-02-16 05:09:00+00:00,EUR_USD,M1,BUY,1.18628,100.0,9999.96,6e-06 +2026-02-16 05:19:00+00:00,EUR_USD,M1,FLAT,1.1862,0.0,9999.95,7e-06 +2026-02-16 05:25:00+00:00,EUR_USD,M1,BUY,1.18635,100.0,9999.95,7e-06 +2026-02-16 05:32:00+00:00,EUR_USD,M1,FLAT,1.18623,0.0,9999.94,8e-06 diff --git a/logs/backtest_trades_EUR_USD_M5.csv b/logs/backtest_trades_EUR_USD_M5.csv new file mode 100644 index 0000000..ff05c8b --- /dev/null +++ b/logs/backtest_trades_EUR_USD_M5.csv @@ -0,0 +1,15 @@ +time,instrument,granularity,side,price,position_size,equity,drawdown +2026-02-13 16:20:00+00:00,EUR_USD,M5,BUY,1.18684,100.0,10000.0,0.0 +2026-02-13 18:15:00+00:00,EUR_USD,M5,FLAT,1.18742,0.0,10000.05,7e-06 +2026-02-13 18:30:00+00:00,EUR_USD,M5,BUY,1.1878,100.0,10000.05,7e-06 +2026-02-13 19:00:00+00:00,EUR_USD,M5,FLAT,1.18712,0.0,9999.99,1.2e-05 +2026-02-13 20:50:00+00:00,EUR_USD,M5,BUY,1.18708,100.0,9999.99,1.2e-05 +2026-02-13 21:40:00+00:00,EUR_USD,M5,FLAT,1.18682,0.0,9999.97,1.5e-05 +2026-02-15 22:10:00+00:00,EUR_USD,M5,BUY,1.18719,100.0,9999.97,1.5e-05 +2026-02-15 22:55:00+00:00,EUR_USD,M5,FLAT,1.18708,0.0,9999.96,1.6e-05 +2026-02-16 01:20:00+00:00,EUR_USD,M5,BUY,1.18645,100.0,9999.96,1.6e-05 +2026-02-16 01:40:00+00:00,EUR_USD,M5,FLAT,1.1864,0.0,9999.96,1.6e-05 +2026-02-16 01:55:00+00:00,EUR_USD,M5,BUY,1.18654,100.0,9999.96,1.6e-05 +2026-02-16 02:15:00+00:00,EUR_USD,M5,FLAT,1.18625,0.0,9999.93,1.8e-05 +2026-02-16 02:30:00+00:00,EUR_USD,M5,BUY,1.18654,100.0,9999.93,1.8e-05 +2026-02-16 04:20:00+00:00,EUR_USD,M5,FLAT,1.18663,0.0,9999.94,1.8e-05 diff --git a/logs/backtest_trades_USD_JPY_M1.csv b/logs/backtest_trades_USD_JPY_M1.csv new file mode 100644 index 0000000..7eed294 --- /dev/null +++ b/logs/backtest_trades_USD_JPY_M1.csv @@ -0,0 +1,11 @@ +time,instrument,granularity,side,price,position_size,equity,drawdown +2026-02-16 03:05:00+00:00,USD_JPY,M1,BUY,153.046,100.0,10000.0,0.0 +2026-02-16 03:19:00+00:00,USD_JPY,M1,FLAT,153.054,0.0,10000.01,3e-06 +2026-02-16 03:34:00+00:00,USD_JPY,M1,BUY,153.057,100.0,10000.01,3e-06 +2026-02-16 04:04:00+00:00,USD_JPY,M1,FLAT,153.06,0.0,10000.01,3e-06 +2026-02-16 04:16:00+00:00,USD_JPY,M1,BUY,153.056,100.0,10000.01,3e-06 +2026-02-16 05:09:00+00:00,USD_JPY,M1,FLAT,153.215,0.0,10000.11,2e-06 +2026-02-16 05:23:00+00:00,USD_JPY,M1,BUY,153.228,100.0,10000.11,2e-06 +2026-02-16 05:29:00+00:00,USD_JPY,M1,FLAT,153.184,0.0,10000.08,5e-06 +2026-02-16 05:30:00+00:00,USD_JPY,M1,BUY,153.214,100.0,10000.08,5e-06 +2026-02-16 05:44:00+00:00,USD_JPY,M1,FLAT,153.205,0.0,10000.08,5e-06 diff --git a/logs/backtest_trades_USD_JPY_M5.csv b/logs/backtest_trades_USD_JPY_M5.csv new file mode 100644 index 0000000..41a22d8 --- /dev/null +++ b/logs/backtest_trades_USD_JPY_M5.csv @@ -0,0 +1,24 @@ +time,instrument,granularity,side,price,position_size,equity,drawdown +2026-02-13 15:15:00+00:00,USD_JPY,M5,BUY,153.089,100.0,10000.0,0.0 +2026-02-13 15:45:00+00:00,USD_JPY,M5,FLAT,152.948,0.0,9999.91,9e-06 +2026-02-13 15:55:00+00:00,USD_JPY,M5,BUY,153.132,100.0,9999.91,9e-06 +2026-02-13 16:20:00+00:00,USD_JPY,M5,FLAT,152.894,0.0,9999.75,2.5e-05 +2026-02-13 17:45:00+00:00,USD_JPY,M5,BUY,152.828,100.0,9999.75,2.5e-05 +2026-02-13 17:50:00+00:00,USD_JPY,M5,FLAT,152.757,0.0,9999.71,2.9e-05 +2026-02-13 17:55:00+00:00,USD_JPY,M5,BUY,152.808,100.0,9999.71,2.9e-05 +2026-02-13 18:00:00+00:00,USD_JPY,M5,FLAT,152.792,0.0,9999.7,3e-05 +2026-02-13 18:05:00+00:00,USD_JPY,M5,BUY,152.818,100.0,9999.7,3e-05 +2026-02-13 19:10:00+00:00,USD_JPY,M5,FLAT,152.81,0.0,9999.69,3.1e-05 +2026-02-13 20:10:00+00:00,USD_JPY,M5,BUY,152.788,100.0,9999.69,3.1e-05 +2026-02-13 20:25:00+00:00,USD_JPY,M5,FLAT,152.734,0.0,9999.65,3.5e-05 +2026-02-13 21:50:00+00:00,USD_JPY,M5,BUY,152.717,100.0,9999.65,3.5e-05 +2026-02-15 22:05:00+00:00,USD_JPY,M5,FLAT,152.632,0.0,9999.6,4e-05 +2026-02-15 23:00:00+00:00,USD_JPY,M5,BUY,152.695,100.0,9999.6,4e-05 +2026-02-16 01:30:00+00:00,USD_JPY,M5,FLAT,153.008,0.0,9999.8,2e-05 +2026-02-16 02:10:00+00:00,USD_JPY,M5,BUY,153.089,100.0,9999.8,2e-05 +2026-02-16 02:55:00+00:00,USD_JPY,M5,FLAT,153.012,0.0,9999.75,2.5e-05 +2026-02-16 03:05:00+00:00,USD_JPY,M5,BUY,153.087,100.0,9999.75,2.5e-05 +2026-02-16 03:20:00+00:00,USD_JPY,M5,FLAT,153.022,0.0,9999.71,2.9e-05 +2026-02-16 03:50:00+00:00,USD_JPY,M5,BUY,153.062,100.0,9999.71,2.9e-05 +2026-02-16 04:05:00+00:00,USD_JPY,M5,FLAT,153.02,0.0,9999.68,3.2e-05 +2026-02-16 04:15:00+00:00,USD_JPY,M5,BUY,153.092,100.0,9999.68,3.2e-05 diff --git a/src/backtester.py b/src/backtester.py new file mode 100644 index 0000000..52287a0 --- /dev/null +++ b/src/backtester.py @@ -0,0 +1,343 @@ +# src/backtester.py +""" +Backtesting engine for fx-quant. +Reads features from Supabase, simulates the SMA cross strategy, +and produces P&L, drawdown, and trade log outputs. +""" + +import os +import math +from pathlib import Path + +import pandas as pd +import numpy as np +from supabase import create_client + +from config_loader import load_config, get_project_root + + +# --------------------------------------------------------------------------- +# Data fetching +# --------------------------------------------------------------------------- + +def fetch_candles_from_supabase(instrument, granularity, supabase_client, table): + """ + Query Supabase fx_candles table filtered by instrument + granularity. + Returns a pandas DataFrame indexed by time, sorted ascending. + Drops rows where sma columns are NaN (warmup period). + """ + print(f"Fetching {instrument} / {granularity} from Supabase...") + + # Supabase JS-style pagination: fetch in pages of 1000 + all_rows = [] + page_size = 1000 + offset = 0 + while True: + resp = ( + supabase_client.table(table) + .select("*") + .eq("instrument", instrument) + .eq("granularity", granularity) + .order("time", desc=False) + .range(offset, offset + page_size - 1) + .execute() + ) + rows = resp.data or [] + all_rows.extend(rows) + if len(rows) < page_size: + break + offset += page_size + + if not all_rows: + print(f" No data found for {instrument} / {granularity}.") + return pd.DataFrame() + + df = pd.DataFrame(all_rows) + df["time"] = pd.to_datetime(df["time"]) + df = df.set_index("time").sort_index() + + # Convert numeric columns from possible string types + numeric_cols = [c for c in df.columns if c not in ("instrument", "granularity")] + for col in numeric_cols: + df[col] = pd.to_numeric(df[col], errors="coerce") + + print(f" Fetched {len(df)} rows.") + return df + + +# --------------------------------------------------------------------------- +# Signal generation +# --------------------------------------------------------------------------- + +def generate_signals(df, strategy_cfg): + """ + Generate trading signals based on strategy config. + Currently supports 'sma_cross' rule only. + + Signal logic (long-only / flat): + sma_short > sma_long → signal = 1 (long) + sma_short < sma_long → signal = 0 (flat) + Position changes only when signal changes. + """ + rule = strategy_cfg["rule"] + if rule != "sma_cross": + raise ValueError(f"Unsupported strategy rule: {rule}") + + short_w = strategy_cfg["params"]["short"] + long_w = strategy_cfg["params"]["long"] + sma_short_col = f"sma_{short_w}" + sma_long_col = f"sma_{long_w}" + + # Drop rows where indicator columns are NaN (warmup) + required_cols = [sma_short_col, sma_long_col, "close", "ret"] + for col in required_cols: + if col not in df.columns: + raise KeyError(f"Missing required column: {col}") + + df = df.dropna(subset=[sma_short_col, sma_long_col, "ret"]).copy() + + # Signal: 1 when short SMA above long SMA, else 0 + df["signal"] = np.where(df[sma_short_col] > df[sma_long_col], 1, 0) + + # Position changes only on crossover (detect changes) + df["position"] = df["signal"] + + return df + + +# --------------------------------------------------------------------------- +# Backtest engine +# --------------------------------------------------------------------------- + +def run_backtest(df, strategy_cfg): + """ + Walk through bars, track position, equity, trades. + Returns dict with equity_curve (Series), trades (list of dicts), metrics (dict). + """ + trade_size_pct = strategy_cfg.get("trade_size_pct_of_equity", 0.01) + max_dd_pct = strategy_cfg.get("max_drawdown_pct", 0.05) + + starting_equity = 10_000.0 + equity = starting_equity + peak_equity = equity + position = 0 # 0 = flat, 1 = long + position_size = 0.0 + stopped = False + + equity_curve = [] + trades = [] + + times = df.index.tolist() + signals = df["signal"].values + closes = df["close"].values + rets = df["ret"].values + + for i in range(len(df)): + bar_time = times[i] + sig = signals[i] + price = closes[i] + bar_ret = rets[i] + + # Check max drawdown stop + if stopped: + equity_curve.append(equity) + continue + + # P&L from existing position + if position == 1 and i > 0: + pnl = position_size * bar_ret + equity += pnl + + # Update peak and drawdown + if equity > peak_equity: + peak_equity = equity + drawdown = (peak_equity - equity) / peak_equity if peak_equity > 0 else 0.0 + + # Max drawdown breached — stop trading + if drawdown >= max_dd_pct: + if position == 1: + trades.append({ + "time": bar_time, + "side": "FLAT", + "price": price, + "position_size": 0.0, + "equity": round(equity, 2), + "drawdown": round(drawdown, 6), + }) + stopped = True + position = 0 + position_size = 0.0 + equity_curve.append(equity) + print(f" Max drawdown {max_dd_pct:.1%} breached at {bar_time}. Stopping.") + continue + + # Position change + if sig != position: + if sig == 1 and position == 0: + # Enter long + position_size = equity * trade_size_pct + trades.append({ + "time": bar_time, + "side": "BUY", + "price": price, + "position_size": round(position_size, 2), + "equity": round(equity, 2), + "drawdown": round(drawdown, 6), + }) + elif sig == 0 and position == 1: + # Exit long + trades.append({ + "time": bar_time, + "side": "FLAT", + "price": price, + "position_size": 0.0, + "equity": round(equity, 2), + "drawdown": round(drawdown, 6), + }) + position_size = 0.0 + position = sig + + equity_curve.append(equity) + + equity_series = pd.Series(equity_curve, index=df.index, name="equity") + metrics = compute_metrics(equity_series, trades, starting_equity) + + return { + "equity_curve": equity_series, + "trades": trades, + "metrics": metrics, + } + + +# --------------------------------------------------------------------------- +# Metrics +# --------------------------------------------------------------------------- + +def compute_metrics(equity_curve, trades, starting_equity=10_000.0): + """ + Compute summary metrics from equity curve and trade list. + """ + final_equity = equity_curve.iloc[-1] if len(equity_curve) > 0 else starting_equity + total_return_pct = ((final_equity - starting_equity) / starting_equity) * 100 + + # Max drawdown from equity curve + peak = equity_curve.cummax() + dd = (peak - equity_curve) / peak + max_drawdown_pct = dd.max() * 100 if len(dd) > 0 else 0.0 + + # Trade stats + num_trades = len(trades) + # Count winning round-trips (BUY followed by FLAT with higher equity) + wins = 0 + buy_equity = None + for t in trades: + if t["side"] == "BUY": + buy_equity = t["equity"] + elif t["side"] == "FLAT" and buy_equity is not None: + if t["equity"] > buy_equity: + wins += 1 + buy_equity = None + + round_trips = sum(1 for t in trades if t["side"] == "FLAT") + win_rate = (wins / round_trips * 100) if round_trips > 0 else 0.0 + + # Sharpe ratio (annualized, from per-bar returns of the equity curve) + eq_returns = equity_curve.pct_change().dropna() + sharpe = 0.0 + if len(eq_returns) > 1 and eq_returns.std() > 0: + sharpe = (eq_returns.mean() / eq_returns.std()) * math.sqrt(252 * 24 * 60) # per-minute approx + + return { + "total_return_pct": round(total_return_pct, 4), + "max_drawdown_pct": round(max_drawdown_pct, 4), + "num_trades": num_trades, + "round_trips": round_trips, + "win_rate_pct": round(win_rate, 2), + "sharpe_ratio": round(sharpe, 4), + "final_equity": round(final_equity, 2), + } + + +# --------------------------------------------------------------------------- +# Results output +# --------------------------------------------------------------------------- + +def save_results(instrument, granularity, trades, metrics): + """ + Save trade log CSV and print summary metrics. + """ + root = get_project_root() + logs_dir = root / "logs" + logs_dir.mkdir(exist_ok=True) + + # Trade log CSV + if trades: + trade_df = pd.DataFrame(trades) + trade_df["instrument"] = instrument + trade_df["granularity"] = granularity + # Reorder columns + cols = ["time", "instrument", "granularity", "side", "price", + "position_size", "equity", "drawdown"] + trade_df = trade_df[cols] + csv_path = logs_dir / f"backtest_trades_{instrument}_{granularity}.csv" + trade_df.to_csv(csv_path, index=False) + print(f" Trade log saved: {csv_path}") + else: + print(" No trades to log.") + + # Console summary + print(f"\n --- {instrument} / {granularity} Summary ---") + print(f" Total return: {metrics['total_return_pct']:.4f}%") + print(f" Max drawdown: {metrics['max_drawdown_pct']:.4f}%") + print(f" Trades: {metrics['num_trades']} (round-trips: {metrics['round_trips']})") + print(f" Win rate: {metrics['win_rate_pct']:.2f}%") + print(f" Sharpe ratio: {metrics['sharpe_ratio']:.4f}") + print(f" Final equity: ${metrics['final_equity']:,.2f}") + print() + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + cfg = load_config() + + # Supabase client + supabase_url = os.getenv("SUPABASE_URL") + supabase_key = os.getenv("SUPABASE_KEY") + if not supabase_url or not supabase_key: + raise SystemExit("Missing SUPABASE_URL or SUPABASE_KEY in config/.env") + sb = create_client(supabase_url, supabase_key) + table = cfg.get("supabase", {}).get("table", "fx_candles") + + strategy_cfg = cfg["strategy"] + instruments = cfg["brokers"][0]["instruments"] + granularities = cfg["data"]["candle_granularities"] + + print(f"Backtesting strategy: {strategy_cfg['rule']}") + print(f"Instruments: {instruments}") + print(f"Granularities: {granularities}\n") + + for instrument in instruments: + for granularity in granularities: + print(f"=== {instrument} / {granularity} ===") + + df = fetch_candles_from_supabase(instrument, granularity, sb, table) + if df.empty: + print(" Skipping — no data.\n") + continue + + df = generate_signals(df, strategy_cfg) + if df.empty: + print(" Skipping — no valid rows after warmup.\n") + continue + + results = run_backtest(df, strategy_cfg) + save_results(instrument, granularity, results["trades"], results["metrics"]) + + print("Backtesting complete.") + + +if __name__ == "__main__": + main()