#!/usr/bin/env python3 """ Multi-symbol backtest. Tests the optimised RSI Trend strategy across every M15 pair in the database with at least MIN_CANDLES bars. Ranks pairs by Sharpe ratio and prints an aggregate portfolio equity curve. """ import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) import sqlite3 import numpy as np from engine.backtest import BacktestEngine from engine.data import load_candles, DB_PATH from engine.metrics import compute from strategies.rsi_trend import RSITrendStrategy try: from reports.chart import save_equity_chart CHARTS = True except ImportError: CHARTS = False # ── config ──────────────────────────────────────────────────────────────────── MIN_CANDLES = 1_000 # require ≥ 1 000 M15 bars (≈ 6 months) EXCLUDE = {"USD_JPY", "EUR_USD", "GBP_USD", "AUD_USD", # OANDA-format dupes "EUR_JPY", "GBP_JPY", "EUR_JPY"} # Optimised params from single-symbol grid search BEST_PARAMS = dict( os_level=30, ob_level=75, sl_atr=2.0, tp_atr=3.0, trend_fast=50, trend_slow=200, session_start=7, session_end=20, ) INITIAL_BALANCE = 10_000.0 RISK_PER_TRADE = 0.01 SPREAD_PIPS = 0.5 # ── helpers ─────────────────────────────────────────────────────────────────── def available_symbols() -> list[tuple[str, int]]: conn = sqlite3.connect(DB_PATH) rows = conn.execute( "SELECT symbol, COUNT(*) FROM candles WHERE timeframe='M15' " "GROUP BY symbol ORDER BY COUNT(*) DESC" ).fetchall() conn.close() return [(sym, cnt) for sym, cnt in rows if cnt >= MIN_CANDLES and sym not in EXCLUDE and "_" not in sym] def bar(val: float, max_val: float, width: int = 20) -> str: filled = int(round(abs(val) / max(abs(max_val), 1e-9) * width)) char = "█" if val >= 0 else "▒" return char * filled # ── main ────────────────────────────────────────────────────────────────────── def main(): symbols = available_symbols() print("=" * 72) print(" Multi-Symbol Backtest — RSI Trend Pullback (optimised params)") print("=" * 72) print(f" Params : {BEST_PARAMS}") print(f" Symbols: {len(symbols)} pairs (min {MIN_CANDLES} M15 candles)") print() engine = BacktestEngine( "MULTI", initial_balance=INITIAL_BALANCE, risk_per_trade=RISK_PER_TRADE, spread_pips=SPREAD_PIPS, ) results = [] for symbol, n_candles in symbols: # pip size is embedded in the engine per symbol — re-create for each sym_engine = BacktestEngine( symbol, initial_balance=INITIAL_BALANCE, risk_per_trade=RISK_PER_TRADE, spread_pips=SPREAD_PIPS, ) df = load_candles(symbol, "M15") strategy = RSITrendStrategy(**BEST_PARAMS) trades, equity = sym_engine.run(df, strategy) m = compute(trades, equity, INITIAL_BALANCE) results.append((symbol, n_candles, m, equity)) status = "+" if m.total_return > 0 else "-" print(f" [{status}] {symbol:<10} {n_candles:>6} bars " f"T={m.n_trades:>3} WR={m.win_rate*100:>5.1f}% " f"PF={m.profit_factor:>5.2f} Sharpe={m.sharpe:>5.2f} " f"Ret={m.total_return*100:>+7.2f}%") # ── ranked table ────────────────────────────────────────────────────────── results.sort(key=lambda x: x[2].sharpe, reverse=True) profitable = [r for r in results if r[2].total_return > 0] losing = [r for r in results if r[2].total_return <= 0] max_ret = max(abs(r[2].total_return) for r in results) if results else 1 print() print("─" * 72) print(f" {'Symbol':<10} {'Bars':>6} {'Trades':>6} {'WR%':>5} {'PF':>5} " f"{'Sharpe':>6} {'Return%':>8} {'MDD%':>6} Return bar") print("─" * 72) for symbol, n_candles, m, _ in results: if m.n_trades == 0: continue b = bar(m.total_return, max_ret) print(f" {symbol:<10} {n_candles:>6} {m.n_trades:>6} {m.win_rate*100:>5.1f} " f"{m.profit_factor:>5.2f} {m.sharpe:>6.2f} {m.total_return*100:>+8.2f} " f"{m.max_drawdown*100:>6.2f} {b}") print("─" * 72) print(f" Profitable: {len(profitable)}/{len(results)} pairs " f"({len(profitable)/len(results)*100:.0f}%)") # ── portfolio equity (equal-weight, 1 trade at a time per symbol) ───────── # Normalise each equity curve to return-fraction, then average if results: min_len = min(len(eq) for _, _, _, eq in results) normed = [eq[:min_len] / eq[0] for _, _, _, eq in results] port_equity = np.mean(normed, axis=0) * INITIAL_BALANCE total_ret = port_equity[-1] / port_equity[0] - 1 peak = np.maximum.accumulate(port_equity) port_mdd = ((port_equity - peak) / peak).min() print() print("── Portfolio (equal-weight average) ─────────────────────────────") print(f" Return : {total_ret*100:+.2f}%") print(f" Max DD : {port_mdd*100:.2f}%") # ── recommended live symbols ─────────────────────────────────────────────── live_candidates = [r for r in results if r[2].sharpe > 1.5 and r[2].n_trades >= 15 and r[2].profit_factor > 1.2 and r[2].total_return > 0] print() print("── Recommended symbols for live trading ─────────────────────────") if live_candidates: for symbol, _, m, _ in live_candidates[:6]: print(f" ✓ {symbol:<10} Sharpe={m.sharpe:.2f} " f"PF={m.profit_factor:.2f} Return={m.total_return*100:+.1f}%") else: print(" (None met Sharpe>1.5 + PF>1.2 + ≥15 trades)") print() print("=" * 72) if __name__ == "__main__": main()