diff --git a/README.md b/README.md index b31a00c..cd46abf 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,27 @@ strategy testing. Typical workflows: - Run `python lab/download_data.py`. - Upload CSV files through the backtesting UI. +### Broker Preset Backtests (XM/FBS) + +You can run a preset batch backtest tuned for broker environments: + +```bash +py -3.12 lab/run_preset_backtests.py +``` + +Optional: + +```bash +py -3.12 lab/run_preset_backtests.py --preset xm_demo.json --tail 5000 +``` + +Preset files are stored in: + +- `config/presets/xm_demo.json` +- `config/presets/fbs_demo.json` + +Results are saved to `logs/preset_backtest_results.json`. + Supported markets depend on the connected MT5 broker. Common examples include: - Forex pairs such as EURUSD, GBPUSD, USDJPY. diff --git a/config/presets/fbs_demo.json b/config/presets/fbs_demo.json new file mode 100644 index 0000000..b1f24ee --- /dev/null +++ b/config/presets/fbs_demo.json @@ -0,0 +1,46 @@ +{ + "preset_name": "FBS Demo Baseline", + "broker_match": ["FBS"], + "symbols": { + "XAUUSD": "XAUUSDw", + "EURUSD": "EURUSDw", + "GBPUSD": "GBPUSDw", + "USDJPY": "USDJPYw", + "BTCUSD": "BTCUSD" + }, + "cases": [ + { + "name": "EURUSDw Conservative Trend", + "symbol": "EURUSD", + "data_file": "backtest_data/EURUSD_H1_data.csv", + "strategy_id": "MA_CROSSOVER", + "params": { + "risk_percent": 0.5, + "sl_atr_multiplier": 2.0, + "tp_atr_multiplier": 4.0 + } + }, + { + "name": "GBPUSDw Mean Reversion", + "symbol": "GBPUSD", + "data_file": "backtest_data/GBPUSD_H1_data.csv", + "strategy_id": "BOLLINGER_REVERSION", + "params": { + "risk_percent": 0.5, + "sl_atr_multiplier": 2.0, + "tp_atr_multiplier": 4.0 + } + }, + { + "name": "XAUUSDw Defensive", + "symbol": "XAUUSD", + "data_file": "backtest_data/XAUUSD_H1_data.csv", + "strategy_id": "TURTLE_BREAKOUT", + "params": { + "risk_percent": 0.25, + "sl_atr_multiplier": 1.0, + "tp_atr_multiplier": 2.0 + } + } + ] +} diff --git a/config/presets/xm_demo.json b/config/presets/xm_demo.json new file mode 100644 index 0000000..af1be7c --- /dev/null +++ b/config/presets/xm_demo.json @@ -0,0 +1,57 @@ +{ + "preset_name": "XM Demo Baseline", + "broker_match": ["XM", "XMGLOBAL"], + "symbols": { + "XAUUSD": "GOLD", + "EURUSD": "EURUSD", + "GBPUSD": "GBPUSD", + "USDJPY": "USDJPY", + "BTCUSD": "BTCUSD" + }, + "cases": [ + { + "name": "EURUSD Conservative Trend", + "symbol": "EURUSD", + "data_file": "backtest_data/EURUSD_H1_data.csv", + "strategy_id": "MA_CROSSOVER", + "params": { + "risk_percent": 0.5, + "sl_atr_multiplier": 2.0, + "tp_atr_multiplier": 4.0 + } + }, + { + "name": "EURUSD Momentum", + "symbol": "EURUSD", + "data_file": "backtest_data/EURUSD_H1_data.csv", + "strategy_id": "RSI_CROSSOVER", + "params": { + "risk_percent": 0.5, + "sl_atr_multiplier": 2.0, + "tp_atr_multiplier": 4.0 + } + }, + { + "name": "GOLD Defensive", + "symbol": "XAUUSD", + "data_file": "backtest_data/GOLD_H1_data.csv", + "strategy_id": "MA_CROSSOVER", + "params": { + "risk_percent": 0.25, + "sl_atr_multiplier": 1.0, + "tp_atr_multiplier": 2.0 + } + }, + { + "name": "BTCUSD Volatility", + "symbol": "BTCUSD", + "data_file": "backtest_data/BTCUSD_H1_data.csv", + "strategy_id": "BOLLINGER_SQUEEZE", + "params": { + "risk_percent": 0.5, + "sl_atr_multiplier": 2.0, + "tp_atr_multiplier": 4.0 + } + } + ] +} diff --git a/lab/run_preset_backtests.py b/lab/run_preset_backtests.py new file mode 100644 index 0000000..f6ef2d0 --- /dev/null +++ b/lab/run_preset_backtests.py @@ -0,0 +1,155 @@ +import argparse +import json +import os +import sys +from pathlib import Path + +import pandas as pd +from dotenv import load_dotenv + +import MetaTrader5 as mt5 +from core.backtesting.enhanced_engine import run_enhanced_backtest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +PRESET_DIR = PROJECT_ROOT / "config" / "presets" + + +def load_mt5_credentials() -> tuple[int, str, str]: + load_dotenv(PROJECT_ROOT / ".env") + login = int(os.getenv("MT5_LOGIN", "0")) + password = os.getenv("MT5_PASSWORD", "") + server = os.getenv("MT5_SERVER", "") + if not login or not password or not server: + raise RuntimeError("MT5 credentials missing in .env") + return login, password, server + + +def detect_broker_server(login: int, password: str, server: str) -> str: + if not mt5.initialize(login=login, password=password, server=server): # type: ignore + raise RuntimeError(f"MT5 init failed: {mt5.last_error()}") # type: ignore + info = mt5.account_info() # type: ignore + if not info: + mt5.shutdown() # type: ignore + raise RuntimeError("MT5 account_info failed") + return str(info.server).upper() + + +def choose_preset(broker_server: str, forced_preset: str | None) -> Path: + if forced_preset: + path = PRESET_DIR / forced_preset + if not path.exists(): + raise FileNotFoundError(f"Preset not found: {path}") + return path + + for preset_path in PRESET_DIR.glob("*.json"): + with open(preset_path, "r", encoding="utf-8") as f: + preset = json.load(f) + for token in preset.get("broker_match", []): + if token.upper() in broker_server: + return preset_path + + return PRESET_DIR / "xm_demo.json" + + +def run_case(case: dict, symbol_map: dict, tail_rows: int) -> dict: + raw_symbol = case["symbol"] + resolved_symbol = symbol_map.get(raw_symbol, raw_symbol) + data_file = PROJECT_ROOT / case["data_file"] + if not data_file.exists(): + return { + "name": case["name"], + "symbol": resolved_symbol, + "strategy": case["strategy_id"], + "status": "missing_data", + "error": f"Missing file: {data_file}", + } + + df = pd.read_csv(data_file, parse_dates=["time"]).tail(tail_rows) + result = run_enhanced_backtest( + case["strategy_id"], + case["params"], + df, + symbol_name=resolved_symbol, + engine_config={ + "enable_spread_costs": True, + "enable_slippage": True, + "enable_realistic_execution": True, + }, + ) + if "error" in result: + return { + "name": case["name"], + "symbol": resolved_symbol, + "strategy": case["strategy_id"], + "status": "error", + "error": result["error"], + } + + return { + "name": case["name"], + "symbol": resolved_symbol, + "strategy": case["strategy_id"], + "status": "ok", + "gross_profit_usd": result.get("gross_profit_usd", 0), + "net_profit_usd": result.get("net_profit_after_costs", 0), + "spread_costs_usd": result.get("total_spread_costs", 0), + "win_rate_percent": result.get("win_rate_percent", 0), + "max_drawdown_percent": result.get("max_drawdown_percent", 0), + "total_trades": result.get("total_trades", 0), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run broker preset backtests") + parser.add_argument("--preset", help="Preset file name in config/presets, e.g. xm_demo.json") + parser.add_argument("--tail", type=int, default=5000, help="Number of latest rows to test per case") + args = parser.parse_args() + + login, password, server = load_mt5_credentials() + broker_server = detect_broker_server(login, password, server) + preset_path = choose_preset(broker_server, args.preset) + + with open(preset_path, "r", encoding="utf-8") as f: + preset = json.load(f) + + print(f"Using broker server: {broker_server}") + print(f"Using preset: {preset_path.name} ({preset.get('preset_name', 'Unnamed')})") + print("") + + rows = [] + for case in preset.get("cases", []): + rows.append(run_case(case, preset.get("symbols", {}), args.tail)) + + mt5.shutdown() # type: ignore + + print("name | symbol | strategy | status | net | gross | spread | win_rate | drawdown | trades") + for r in rows: + if r["status"] != "ok": + print(f"{r['name']} | {r['symbol']} | {r['strategy']} | {r['status']} | {r.get('error', '')}") + continue + print( + f"{r['name']} | {r['symbol']} | {r['strategy']} | ok | " + f"{r['net_profit_usd']:.2f} | {r['gross_profit_usd']:.2f} | {r['spread_costs_usd']:.2f} | " + f"{r['win_rate_percent']:.2f}% | {r['max_drawdown_percent']:.2f}% | {r['total_trades']}" + ) + + out_dir = PROJECT_ROOT / "logs" + out_dir.mkdir(exist_ok=True) + out_file = out_dir / "preset_backtest_results.json" + with open(out_file, "w", encoding="utf-8") as f: + json.dump( + { + "broker_server": broker_server, + "preset_file": preset_path.name, + "results": rows, + }, + f, + indent=2, + ) + print(f"\nSaved: {out_file}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())