ad8dfa27d7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
164 lines
6.4 KiB
Python
164 lines
6.4 KiB
Python
#!/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()
|