Initial commit: multi-symbol bot with backtest engine and RSI trend strategy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 21:09:12 +02:00
commit ad8dfa27d7
32 changed files with 2644 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
#!/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()