605faf5310
Add cluster audit pipeline, united EA updates, brochure generators, and publication hygiene (gitignore, MT5 path desensitization, pre-upload scan). Remove tracked reports, models, and binary artifacts from the repo. Co-authored-by: Cursor <cursoragent@cursor.com>
471 lines
14 KiB
Python
471 lines
14 KiB
Python
"""
|
|
DarvasBoxXAUUSD bar backtest — mirrors main.mq5 inputs and logic.
|
|
|
|
Outputs (in this folder):
|
|
backtest_report.json, trades.csv, report.png,
|
|
equity_curve.png, drawdown.png, monthly_returns.png,
|
|
pnl_distribution.png, exit_reasons.png
|
|
|
|
Usage:
|
|
python run_backtest.py
|
|
python run_backtest.py --start 2021-01-01 --end 2026-01-01
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
import MetaTrader5 as mt5
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
sys.path.insert(0, str(ROOT / "backtesting" / "MT5"))
|
|
|
|
from cluster_audit.backtest_core import ( # noqa: E402
|
|
BacktestReport,
|
|
CostModel,
|
|
Trade,
|
|
build_report,
|
|
calc_profit,
|
|
fill_price,
|
|
load_bars,
|
|
resolve_symbol,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class DarvasParams:
|
|
box_period: int = 165
|
|
box_deviation: float = 25140.0
|
|
volume_threshold: int = 938
|
|
stop_loss_pts: float = 1665.0
|
|
take_profit_pts: float = 3685.0
|
|
ma_period: int = 125
|
|
trend_threshold: float = 4.94
|
|
volume_ma_period: int = 110
|
|
volume_threshold_multiplier: float = 1.5
|
|
lot_size: float = 0.01
|
|
initial_balance: float = 10_000.0
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
|
|
def weighted_price(df: pd.DataFrame) -> pd.Series:
|
|
return (df["high"] + df["low"] + df["close"]) / 3.0
|
|
|
|
|
|
def align_higher_tf_ma(h1_index: pd.DatetimeIndex, h2_ma: pd.Series) -> np.ndarray:
|
|
aligned = h2_ma.reindex(h1_index, method="ffill")
|
|
return aligned.to_numpy()
|
|
|
|
|
|
def volume_ma_ratio(vols: np.ndarray, i: int, period: int) -> float:
|
|
if i < period:
|
|
return 0.0
|
|
window = vols[i - period : i]
|
|
if len(window) == 0:
|
|
return 0.0
|
|
vma = float(np.mean(window))
|
|
if vma <= 0:
|
|
return 0.0
|
|
return float(vols[i]) / vma
|
|
|
|
|
|
def backtest_darvas_unit(
|
|
h1: pd.DataFrame,
|
|
h2_ma: np.ndarray,
|
|
symbol: str,
|
|
params: DarvasParams,
|
|
costs: CostModel,
|
|
period_label: str,
|
|
) -> BacktestReport:
|
|
info = mt5.symbol_info(symbol)
|
|
point = float(info.point) if info else 0.01
|
|
max_range = params.box_deviation * point
|
|
sl_dist = params.stop_loss_pts * point
|
|
tp_dist = params.take_profit_pts * point
|
|
|
|
highs = h1["high"].to_numpy()
|
|
lows = h1["low"].to_numpy()
|
|
opens = h1["open"].to_numpy()
|
|
closes = h1["close"].to_numpy()
|
|
vols = h1["tick_volume"].to_numpy() if "tick_volume" in h1.columns else np.zeros(len(h1))
|
|
|
|
trades: list[Trade] = []
|
|
equity = [params.initial_balance]
|
|
side: str | None = None
|
|
entry = 0.0
|
|
entry_i = 0
|
|
entry_time = None
|
|
sl = 0.0
|
|
tp = 0.0
|
|
|
|
warmup = params.box_period + params.ma_period + params.volume_ma_period + 2
|
|
|
|
def close_pos(i: int, mid: float, reason: str) -> None:
|
|
nonlocal side, entry, entry_i, entry_time, sl, tp
|
|
if side is None:
|
|
return
|
|
exit_px = fill_price(mid, point, costs, side, entry=False)
|
|
commission = costs.commission_per_lot * params.lot_size * 2.0
|
|
profit = calc_profit(symbol, side, params.lot_size, entry, exit_px) - commission
|
|
trades.append(
|
|
Trade(
|
|
side=side,
|
|
open_time=entry_time,
|
|
close_time=h1.index[i],
|
|
open_price=entry,
|
|
close_price=exit_px,
|
|
volume=params.lot_size,
|
|
profit=profit,
|
|
bars_held=i - entry_i,
|
|
exit_reason=reason,
|
|
)
|
|
)
|
|
equity.append(equity[-1] + profit)
|
|
side = None
|
|
|
|
def open_pos(i: int, order_side: str, mid: float) -> None:
|
|
nonlocal side, entry, entry_i, entry_time, sl, tp
|
|
side = order_side
|
|
entry = fill_price(mid, point, costs, order_side, entry=True)
|
|
entry_i = i
|
|
entry_time = h1.index[i]
|
|
if order_side == "BUY":
|
|
sl = entry - sl_dist
|
|
tp = entry + tp_dist
|
|
else:
|
|
sl = entry + sl_dist
|
|
tp = entry - tp_dist
|
|
|
|
def trend_ok(i: int, order_side: str, price: float) -> bool:
|
|
ma_v = float(h2_ma[i - 1])
|
|
if np.isnan(ma_v):
|
|
return False
|
|
strength = abs(price - ma_v) / point
|
|
if order_side == "BUY":
|
|
return price > ma_v and strength > params.trend_threshold
|
|
return price < ma_v and strength > params.trend_threshold
|
|
|
|
for i in range(warmup, len(h1)):
|
|
bar_hi = float(highs[i])
|
|
bar_lo = float(lows[i])
|
|
mid = float(opens[i])
|
|
|
|
if side:
|
|
if side == "BUY":
|
|
if sl > 0 and bar_lo <= sl:
|
|
close_pos(i, sl, "sl")
|
|
elif tp > 0 and bar_hi >= tp:
|
|
close_pos(i, tp, "tp")
|
|
else:
|
|
if sl > 0 and bar_hi >= sl:
|
|
close_pos(i, sl, "sl")
|
|
elif tp > 0 and bar_lo <= tp:
|
|
close_pos(i, tp, "tp")
|
|
if len(equity) == len(trades) + 1:
|
|
equity.append(equity[-1])
|
|
continue
|
|
|
|
window_hi = float(np.max(highs[i - params.box_period : i]))
|
|
window_lo = float(np.min(lows[i - params.box_period : i]))
|
|
if (window_hi - window_lo) > max_range:
|
|
equity.append(equity[-1])
|
|
continue
|
|
|
|
box_high, box_low = window_hi, window_lo
|
|
cur_vol = float(vols[i])
|
|
if cur_vol <= params.volume_threshold:
|
|
equity.append(equity[-1])
|
|
continue
|
|
|
|
vol_ratio = volume_ma_ratio(vols, i, params.volume_ma_period)
|
|
if vol_ratio <= params.volume_threshold_multiplier:
|
|
equity.append(equity[-1])
|
|
continue
|
|
|
|
ask_price = mid
|
|
break_up = bar_hi > box_high or float(closes[i - 1]) > box_high
|
|
break_dn = bar_lo < box_low or float(closes[i - 1]) < box_low
|
|
|
|
if break_up and trend_ok(i, "BUY", ask_price):
|
|
open_pos(i, "BUY", mid)
|
|
elif break_dn and trend_ok(i, "SELL", ask_price):
|
|
open_pos(i, "SELL", mid)
|
|
|
|
equity.append(equity[-1])
|
|
|
|
if side:
|
|
close_pos(len(h1) - 1, float(closes[-1]), "eod")
|
|
|
|
eq = pd.Series(equity[: len(h1)], index=h1.index[: len(equity)])
|
|
return build_report(
|
|
"DarvasBoxXAUUSD",
|
|
symbol,
|
|
"H1",
|
|
period_label,
|
|
trades,
|
|
eq,
|
|
params.initial_balance,
|
|
params.to_dict(),
|
|
)
|
|
|
|
|
|
def plot_dashboard(report: BacktestReport, out_dir: Path) -> None:
|
|
trades = report.trades_list
|
|
if not trades:
|
|
fig, ax = plt.subplots(figsize=(10, 4))
|
|
ax.text(0.5, 0.5, "No trades in backtest window", ha="center", va="center", fontsize=14)
|
|
ax.axis("off")
|
|
fig.savefig(out_dir / "report.png", dpi=200, bbox_inches="tight")
|
|
plt.close(fig)
|
|
return
|
|
|
|
df = pd.DataFrame(
|
|
[
|
|
{
|
|
"open_time": t.open_time,
|
|
"close_time": t.close_time,
|
|
"profit": t.profit,
|
|
"exit_reason": t.exit_reason,
|
|
"side": t.side,
|
|
}
|
|
for t in trades
|
|
]
|
|
)
|
|
df["close_time"] = pd.to_datetime(df["close_time"])
|
|
df = df.sort_values("close_time")
|
|
cumulative = df["profit"].cumsum()
|
|
equity = report.params.get("initial_balance", 10_000.0) + cumulative
|
|
|
|
fig = plt.figure(figsize=(14, 10))
|
|
gs = fig.add_gridspec(3, 2, height_ratios=[2, 1.2, 1.2])
|
|
|
|
ax1 = fig.add_subplot(gs[0, :])
|
|
ax1.plot(df["close_time"], equity, color="#1f77b4", lw=1.8)
|
|
ax1.axhline(report.params.get("initial_balance", 10_000.0), color="gray", ls="--", lw=1)
|
|
ax1.set_title("Equity Curve")
|
|
ax1.set_ylabel("Balance")
|
|
ax1.grid(alpha=0.3)
|
|
|
|
ax2 = fig.add_subplot(gs[1, 0])
|
|
peak = equity.cummax()
|
|
dd = (equity - peak) / peak * 100.0
|
|
ax2.fill_between(df["close_time"], dd, 0, color="#d62728", alpha=0.35)
|
|
ax2.plot(df["close_time"], dd, color="#8b0000", lw=1)
|
|
ax2.set_title("Drawdown %")
|
|
ax2.grid(alpha=0.3)
|
|
|
|
ax3 = fig.add_subplot(gs[1, 1])
|
|
df["month"] = df["close_time"].dt.to_period("M")
|
|
monthly = df.groupby("month")["profit"].sum()
|
|
colors = ["#2ca02c" if v >= 0 else "#d62728" for v in monthly]
|
|
ax3.bar(range(len(monthly)), monthly.values, color=colors, alpha=0.8)
|
|
ax3.set_title("Monthly PnL")
|
|
ax3.set_xticks(range(0, len(monthly), max(1, len(monthly) // 8)))
|
|
ax3.set_xticklabels([str(monthly.index[i]) for i in range(0, len(monthly), max(1, len(monthly) // 8))], rotation=45, ha="right")
|
|
ax3.axhline(0, color="black", lw=0.6)
|
|
ax3.grid(alpha=0.3, axis="y")
|
|
|
|
ax4 = fig.add_subplot(gs[2, 0])
|
|
ax4.hist(df["profit"], bins=30, color="#9467bd", alpha=0.85, edgecolor="white")
|
|
ax4.axvline(0, color="black", lw=0.8)
|
|
ax4.set_title("Trade PnL Distribution")
|
|
ax4.grid(alpha=0.3)
|
|
|
|
ax5 = fig.add_subplot(gs[2, 1])
|
|
reasons = df["exit_reason"].value_counts()
|
|
ax5.bar(reasons.index.astype(str), reasons.values, color="#ff7f0e", alpha=0.85)
|
|
ax5.set_title("Exit Reasons")
|
|
ax5.grid(alpha=0.3, axis="y")
|
|
|
|
summary = (
|
|
f"Net: ${report.net_profit:,.2f} | Trades: {report.total_trades} | "
|
|
f"WR: {report.win_rate:.1f}% | PF: {report.profit_factor:.2f} | "
|
|
f"MaxDD: {report.max_drawdown_pct:.2f}% | Sharpe: {report.sharpe:.2f}"
|
|
)
|
|
fig.suptitle(f"DarvasBoxXAUUSD — {summary}", fontsize=11, y=0.98)
|
|
fig.tight_layout(rect=[0, 0, 1, 0.96])
|
|
fig.savefig(out_dir / "report.png", dpi=200, bbox_inches="tight")
|
|
plt.close(fig)
|
|
|
|
|
|
def plot_equity(report: BacktestReport, path: Path) -> None:
|
|
trades = report.trades_list
|
|
if not trades:
|
|
return
|
|
df = pd.DataFrame([{"close_time": t.close_time, "profit": t.profit} for t in trades])
|
|
df["close_time"] = pd.to_datetime(df["close_time"])
|
|
df = df.sort_values("close_time")
|
|
equity = report.params.get("initial_balance", 10_000.0) + df["profit"].cumsum()
|
|
plt.figure(figsize=(12, 5))
|
|
plt.plot(df["close_time"], equity, lw=2)
|
|
plt.title("Equity Curve")
|
|
plt.xlabel("Time")
|
|
plt.ylabel("Balance")
|
|
plt.grid(alpha=0.3)
|
|
plt.tight_layout()
|
|
plt.savefig(path, dpi=200, bbox_inches="tight")
|
|
plt.close()
|
|
|
|
|
|
def plot_drawdown(report: BacktestReport, path: Path) -> None:
|
|
trades = report.trades_list
|
|
if not trades:
|
|
return
|
|
df = pd.DataFrame([{"close_time": t.close_time, "profit": t.profit} for t in trades])
|
|
df["close_time"] = pd.to_datetime(df["close_time"])
|
|
df = df.sort_values("close_time")
|
|
equity = report.params.get("initial_balance", 10_000.0) + df["profit"].cumsum()
|
|
dd = (equity - equity.cummax()) / equity.cummax() * 100.0
|
|
plt.figure(figsize=(12, 5))
|
|
plt.fill_between(df["close_time"], dd, 0, color="red", alpha=0.3)
|
|
plt.plot(df["close_time"], dd, color="darkred", lw=1)
|
|
plt.title("Drawdown %")
|
|
plt.xlabel("Time")
|
|
plt.ylabel("Drawdown (%)")
|
|
plt.grid(alpha=0.3)
|
|
plt.tight_layout()
|
|
plt.savefig(path, dpi=200, bbox_inches="tight")
|
|
plt.close()
|
|
|
|
|
|
def plot_monthly(report: BacktestReport, path: Path) -> None:
|
|
trades = report.trades_list
|
|
if not trades:
|
|
return
|
|
df = pd.DataFrame([{"close_time": t.close_time, "profit": t.profit} for t in trades])
|
|
df["close_time"] = pd.to_datetime(df["close_time"])
|
|
df["month"] = df["close_time"].dt.to_period("M")
|
|
monthly = df.groupby("month")["profit"].sum()
|
|
colors = ["green" if v >= 0 else "red" for v in monthly]
|
|
plt.figure(figsize=(12, 5))
|
|
plt.bar(range(len(monthly)), monthly.values, color=colors, alpha=0.75)
|
|
plt.xticks(range(len(monthly)), [str(x) for x in monthly.index], rotation=45, ha="right")
|
|
plt.axhline(0, color="black", lw=0.5)
|
|
plt.title("Monthly PnL")
|
|
plt.ylabel("Profit")
|
|
plt.grid(alpha=0.3, axis="y")
|
|
plt.tight_layout()
|
|
plt.savefig(path, dpi=200, bbox_inches="tight")
|
|
plt.close()
|
|
|
|
|
|
def plot_pnl_hist(report: BacktestReport, path: Path) -> None:
|
|
profits = [t.profit for t in report.trades_list]
|
|
if not profits:
|
|
return
|
|
plt.figure(figsize=(10, 5))
|
|
plt.hist(profits, bins=40, color="#6a5acd", alpha=0.85, edgecolor="white")
|
|
plt.axvline(0, color="black", lw=0.8)
|
|
plt.title("Per-Trade PnL Distribution")
|
|
plt.xlabel("Profit")
|
|
plt.grid(alpha=0.3)
|
|
plt.tight_layout()
|
|
plt.savefig(path, dpi=200, bbox_inches="tight")
|
|
plt.close()
|
|
|
|
|
|
def plot_exit_reasons(report: BacktestReport, path: Path) -> None:
|
|
if not report.exit_reason_breakdown:
|
|
return
|
|
labels = list(report.exit_reason_breakdown.keys())
|
|
counts = [report.exit_reason_breakdown[k]["count"] for k in labels]
|
|
plt.figure(figsize=(8, 5))
|
|
plt.bar(labels, counts, color="#e377c2", alpha=0.85)
|
|
plt.title("Exit Reason Counts")
|
|
plt.grid(alpha=0.3, axis="y")
|
|
plt.tight_layout()
|
|
plt.savefig(path, dpi=200, bbox_inches="tight")
|
|
plt.close()
|
|
|
|
|
|
def export_trades_csv(report: BacktestReport, path: Path) -> None:
|
|
rows = []
|
|
for t in report.trades_list:
|
|
rows.append(
|
|
{
|
|
"side": t.side,
|
|
"open_time": t.open_time,
|
|
"close_time": t.close_time,
|
|
"open_price": t.open_price,
|
|
"close_price": t.close_price,
|
|
"volume": t.volume,
|
|
"profit": t.profit,
|
|
"bars_held": t.bars_held,
|
|
"exit_reason": t.exit_reason,
|
|
}
|
|
)
|
|
pd.DataFrame(rows).to_csv(path, index=False)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
p = argparse.ArgumentParser(description="DarvasBoxXAUUSD Python backtest")
|
|
p.add_argument("--symbol", default="XAUUSD")
|
|
p.add_argument("--start", default="2021-01-01")
|
|
p.add_argument("--end", default="2026-01-01")
|
|
p.add_argument("--balance", type=float, default=10_000.0)
|
|
return p.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
out_dir = Path(__file__).resolve().parent
|
|
params = DarvasParams(initial_balance=args.balance)
|
|
|
|
if not mt5.initialize():
|
|
raise SystemExit("MetaTrader5 initialize() failed — open MT5 and log in.")
|
|
|
|
try:
|
|
symbol = resolve_symbol(args.symbol)
|
|
start = datetime.fromisoformat(args.start)
|
|
end = datetime.fromisoformat(args.end)
|
|
period_label = f"{args.start}_{args.end}"
|
|
|
|
print(f"Loading H1/H2 bars for {symbol} ...")
|
|
h1 = load_bars(symbol, mt5.TIMEFRAME_H1, start, end)
|
|
h2 = load_bars(symbol, mt5.TIMEFRAME_H2, start, end)
|
|
h2_ma = weighted_price(h2).ewm(span=params.ma_period, adjust=False).mean()
|
|
ma_on_h1 = align_higher_tf_ma(h1.index, h2_ma)
|
|
|
|
costs = CostModel.for_symbol(symbol)
|
|
report = backtest_darvas_unit(h1, ma_on_h1, symbol, params, costs, period_label)
|
|
|
|
export_trades_csv(report, out_dir / "trades.csv")
|
|
with open(out_dir / "backtest_report.json", "w", encoding="utf-8") as f:
|
|
json.dump(report.to_dict(), f, indent=2, ensure_ascii=False)
|
|
|
|
plot_dashboard(report, out_dir)
|
|
plot_equity(report, out_dir / "equity_curve.png")
|
|
plot_drawdown(report, out_dir / "drawdown.png")
|
|
plot_monthly(report, out_dir / "monthly_returns.png")
|
|
plot_pnl_hist(report, out_dir / "pnl_distribution.png")
|
|
plot_exit_reasons(report, out_dir / "exit_reasons.png")
|
|
|
|
print("\n=== DarvasBoxXAUUSD Backtest ===")
|
|
print(f"Symbol: {symbol}")
|
|
print(f"Period: {period_label}")
|
|
print(f"Net profit: ${report.net_profit:,.2f}")
|
|
print(f"Trades: {report.total_trades}")
|
|
print(f"Win rate: {report.win_rate:.2f}%")
|
|
print(f"Profit fac: {report.profit_factor:.2f}")
|
|
print(f"Max DD: {report.max_drawdown_pct:.2f}%")
|
|
print(f"Sharpe: {report.sharpe:.2f}")
|
|
print(f"\nReports saved to: {out_dir}")
|
|
finally:
|
|
mt5.shutdown()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|