Files
zhutoutoutousan 605faf5310 Prepare source-only public release for develop.
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>
2026-07-02 15:03:43 +02:00

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()