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>
293 lines
11 KiB
Python
293 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Per-symbol v5 optimization + portfolio assembly."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import random
|
|
import sys
|
|
from dataclasses import asdict, replace
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import MetaTrader5 as mt5
|
|
import pandas as pd
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
LAB = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(ROOT / "backtesting" / "MT5"))
|
|
sys.path.insert(0, str(LAB))
|
|
|
|
from cluster_audit.backtest_core import CostModel, load_bars, resolve_symbol # noqa: E402
|
|
from run_backtest import pip_size # noqa: E402
|
|
from run_optimize_v5 import seed_params # noqa: E402
|
|
from run_portfolio_v5 import load_portfolio_config, portfolio_metrics # noqa: E402
|
|
from strategy_v5 import V5Params, load_v5_cache, market_from_cache, sample_v5, simulate_v5 # noqa: E402
|
|
|
|
OUT_PATH = LAB / "portfolio_params.json"
|
|
TRIALS_DIR = LAB / "portfolio_opt_trials"
|
|
|
|
|
|
def is_metal(name: str) -> bool:
|
|
base = name.upper().split(".")[0]
|
|
return base.startswith("XAU") or base.startswith("XAG")
|
|
|
|
|
|
def is_index(name: str) -> bool:
|
|
base = name.upper().split(".")[0]
|
|
return base in {"US500", "NAS100", "US30", "GER40", "UK100", "JPN225", "SPX500", "USTEC"}
|
|
|
|
|
|
def is_crypto(name: str) -> bool:
|
|
base = name.upper().split(".")[0]
|
|
return base.startswith("BTC") or base.startswith("ETH")
|
|
|
|
|
|
def high_freq_seeds() -> list[V5Params]:
|
|
return [
|
|
V5Params(fast_ema=8, slow_ema=30, cross_cooldown=2, pullback_cooldown=2, use_pullback=True, trend_leg_bars=48),
|
|
V5Params(fast_ema=9, slow_ema=34, cross_cooldown=3, pullback_cooldown=2, use_pullback=True, htf_ema_period=100),
|
|
V5Params(fast_ema=10, slow_ema=36, cross_cooldown=2, use_pullback=False, htf_ema_period=100),
|
|
V5Params(fast_ema=11, slow_ema=40, cross_cooldown=4, use_pullback=True, pullback_touch=1, pullback_adx_min=20),
|
|
]
|
|
|
|
|
|
def all_seeds() -> list[V5Params]:
|
|
return seed_params() + high_freq_seeds()
|
|
|
|
|
|
def sample_for_symbol(rng: random.Random, name: str) -> V5Params:
|
|
p = sample_v5(rng)
|
|
p.cross_cooldown = rng.choice([2, 3, 4, 5, 6])
|
|
p.pullback_cooldown = rng.choice([2, 3, 4])
|
|
if is_metal(name):
|
|
p.max_spread_pips = rng.choice([30.0, 35.0, 40.0, 50.0, 60.0])
|
|
p.min_ema_gap_pips = round(rng.uniform(1.0, 4.0), 1)
|
|
p.atr_sl_mult = round(rng.uniform(2.0, 3.5), 2)
|
|
p.atr_tp_mult = round(rng.uniform(3.5, 6.5), 2)
|
|
elif is_index(name) or is_crypto(name):
|
|
p.max_spread_pips = rng.choice([15.0, 20.0, 30.0, 40.0, 50.0])
|
|
p.min_ema_gap_pips = round(rng.uniform(2.0, 8.0), 1)
|
|
p.atr_sl_mult = round(rng.uniform(2.0, 3.2), 2)
|
|
p.atr_tp_mult = round(rng.uniform(3.0, 5.5), 2)
|
|
elif "JPY" in name.upper():
|
|
p.max_spread_pips = rng.choice([8.0, 10.0, 12.0, 15.0, 18.0])
|
|
return p
|
|
|
|
|
|
def score_result(r, min_trades: int) -> float:
|
|
if r.total_trades < min_trades:
|
|
return -1e6 + r.net_profit
|
|
if r.net_profit > 0 and r.profit_factor >= 1.05:
|
|
return r.net_profit + r.total_trades * 4.0
|
|
if r.net_profit > 0 and r.profit_factor >= 1.0:
|
|
return r.net_profit + r.total_trades * 2.0
|
|
return r.net_profit + r.total_trades * 0.1
|
|
|
|
|
|
def pick_best(trials: list[tuple], min_trades: int) -> tuple | None:
|
|
if not trials:
|
|
return None
|
|
profitable = [t for t in trials if t[0].net_profit > 0 and t[0].profit_factor >= 1.03 and t[0].total_trades >= min_trades]
|
|
if profitable:
|
|
return max(profitable, key=lambda t: score_result(t[0], min_trades))
|
|
positive = [t for t in trials if t[0].net_profit > 0 and t[0].total_trades >= min_trades]
|
|
if positive:
|
|
return max(positive, key=lambda t: score_result(t[0], min_trades))
|
|
return max(trials, key=lambda t: score_result(t[0], min_trades))
|
|
|
|
|
|
def optimize_symbol(
|
|
req: str,
|
|
spread_cap: float,
|
|
lot: float,
|
|
start: datetime,
|
|
end: datetime,
|
|
trials: int,
|
|
min_trades: int,
|
|
seed: int,
|
|
) -> dict:
|
|
sym = resolve_symbol(req)
|
|
df = load_bars(sym, mt5.TIMEFRAME_M15, start, end)
|
|
cache = load_v5_cache(df)
|
|
pip = pip_size(sym)
|
|
point = float(mt5.symbol_info(sym).point)
|
|
costs = CostModel.for_symbol(sym)
|
|
rng = random.Random(hash(sym) ^ seed)
|
|
|
|
results: list[tuple] = []
|
|
seeds = all_seeds()
|
|
for p0 in seeds:
|
|
p = replace(p0, lot_size=lot, max_spread_pips=spread_cap)
|
|
r = simulate_v5(market_from_cache(cache, p), sym, p, costs, pip, point)
|
|
results.append((r, p))
|
|
|
|
for _ in range(max(0, trials - len(seeds))):
|
|
p = replace(sample_for_symbol(rng, sym), lot_size=lot, max_spread_pips=spread_cap)
|
|
r = simulate_v5(market_from_cache(cache, p), sym, p, costs, pip, point)
|
|
results.append((r, p))
|
|
|
|
best_r, best_p = pick_best(results, min_trades)
|
|
assert best_r and best_p
|
|
|
|
enabled = best_r.net_profit > 0 and best_r.profit_factor >= 1.0 and best_r.total_trades >= min_trades
|
|
row = {
|
|
"requested": req,
|
|
"symbol": sym,
|
|
"enabled": True,
|
|
"max_spread_pips": spread_cap,
|
|
"params": asdict(best_p),
|
|
"metrics": {k: v for k, v in asdict(best_r).items() if k != "trades"},
|
|
"score": round(score_result(best_r, min_trades), 2),
|
|
}
|
|
|
|
TRIALS_DIR.mkdir(exist_ok=True)
|
|
pd.DataFrame(
|
|
[{"net": r.net_profit, "trades": r.total_trades, "pf": r.profit_factor, **asdict(p)} for r, p in results]
|
|
).to_csv(TRIALS_DIR / f"{sym.replace('.', '_')}.csv", index=False)
|
|
|
|
flag = "PY" if enabled else "py-"
|
|
print(
|
|
f" [{flag}] {sym}: net=${best_r.net_profit:,.0f} t={best_r.total_trades} "
|
|
f"PF={best_r.profit_factor:.2f} WR={best_r.win_rate:.1f}%"
|
|
)
|
|
return row
|
|
|
|
|
|
def run_portfolio_backtest(members: list[dict], start: datetime, end: datetime, initial: float) -> tuple[pd.DataFrame, list[dict], dict]:
|
|
all_trades: list[dict] = []
|
|
sym_rows: list[dict] = []
|
|
|
|
for m in members:
|
|
if not m.get("enabled", True):
|
|
continue
|
|
sym = m["symbol"]
|
|
p = V5Params(**m["params"])
|
|
df = load_bars(sym, mt5.TIMEFRAME_M15, start, end)
|
|
pip = pip_size(sym)
|
|
point = float(mt5.symbol_info(sym).point)
|
|
costs = CostModel.for_symbol(sym)
|
|
r = simulate_v5(market_from_cache(load_v5_cache(df), p), sym, p, costs, pip, point)
|
|
for t in r.trades:
|
|
all_trades.append(
|
|
{
|
|
"symbol": sym,
|
|
"side": t["side"],
|
|
"open_time": df.index[t["open_i"]],
|
|
"close_time": df.index[t["close_i"]],
|
|
"profit": round(t["profit"], 2),
|
|
"exit_reason": t["exit_reason"],
|
|
}
|
|
)
|
|
sym_rows.append(
|
|
{
|
|
"symbol": sym,
|
|
"enabled": True,
|
|
"trades": r.total_trades,
|
|
"net_profit": round(r.net_profit, 2),
|
|
"profit_factor": round(r.profit_factor, 2),
|
|
"win_rate": round(r.win_rate, 1),
|
|
}
|
|
)
|
|
|
|
tdf = pd.DataFrame(all_trades).sort_values(["close_time", "symbol"]) if all_trades else pd.DataFrame()
|
|
metrics = portfolio_metrics(tdf, initial)
|
|
metrics["target_met_2000_trades"] = metrics["total_trades"] >= 2000
|
|
metrics["target_met_profit"] = metrics["net_profit"] > 0
|
|
return tdf, sym_rows, metrics
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--config", type=Path, default=LAB / "portfolio_symbols.json")
|
|
ap.add_argument("--trials", type=int, default=350, help="trials per symbol")
|
|
ap.add_argument("--min-trades", type=int, default=15)
|
|
ap.add_argument("--min-pf", type=float, default=1.0)
|
|
ap.add_argument("--seed", type=int, default=42)
|
|
ap.add_argument("--skip-opt", action="store_true", help="only rebuild portfolio from existing portfolio_params.json")
|
|
args = ap.parse_args()
|
|
|
|
cfg = load_portfolio_config(args.config)
|
|
start = datetime.fromisoformat(cfg["period"][0])
|
|
end = datetime.fromisoformat(cfg["period"][1])
|
|
lot = cfg.get("lot_per_symbol", 0.05)
|
|
initial = cfg.get("initial_balance", 10000.0)
|
|
|
|
if not mt5.initialize():
|
|
raise SystemExit("MT5 init failed")
|
|
try:
|
|
members: list[dict] = []
|
|
if not args.skip_opt:
|
|
print(f"Per-symbol optimize: {len(cfg['symbols'])} symbols x {args.trials} trials")
|
|
for entry in cfg["symbols"]:
|
|
try:
|
|
members.append(
|
|
optimize_symbol(
|
|
entry["name"],
|
|
entry.get("max_spread_pips", 8.0),
|
|
lot,
|
|
start,
|
|
end,
|
|
args.trials,
|
|
args.min_trades,
|
|
args.seed,
|
|
)
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f" FAIL {entry['name']}: {exc}")
|
|
members.append(
|
|
{
|
|
"requested": entry["name"],
|
|
"symbol": entry["name"],
|
|
"enabled": False,
|
|
"error": str(exc),
|
|
}
|
|
)
|
|
else:
|
|
existing = json.loads(OUT_PATH.read_text(encoding="utf-8"))
|
|
members = existing["members"]
|
|
|
|
enabled_n = sum(1 for m in members if m.get("enabled"))
|
|
print(f"\nPortfolio assembly: {enabled_n}/{len(members)} symbols enabled")
|
|
|
|
tdf, sym_rows, metrics = run_portfolio_backtest(members, start, end, initial)
|
|
|
|
out = LAB / "best_run"
|
|
out.mkdir(exist_ok=True)
|
|
tdf.to_csv(out / "portfolio_trades.csv", index=False)
|
|
pd.DataFrame(sym_rows).to_csv(out / "portfolio_by_symbol.csv", index=False)
|
|
|
|
payload = {
|
|
"version": 5,
|
|
"mode": "per_symbol_optimized",
|
|
"optimized_at": datetime.now().isoformat(timespec="seconds"),
|
|
"config": cfg,
|
|
"selection": {
|
|
"min_trades": args.min_trades,
|
|
"min_pf": args.min_pf,
|
|
"trials_per_symbol": args.trials,
|
|
},
|
|
"members": members,
|
|
"portfolio_metrics": metrics,
|
|
"per_symbol_live": sym_rows,
|
|
}
|
|
OUT_PATH.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
(LAB / "portfolio_report.json").write_text(
|
|
json.dumps({"metrics": metrics, "per_symbol": sym_rows, "enabled_count": enabled_n}, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
print(
|
|
f"\nPORTFOLIO: net=${metrics['net_profit']:,.0f} trades={metrics['total_trades']} "
|
|
f"PF={metrics['profit_factor']:.2f} WR={metrics['win_rate']:.1f}% DD={metrics['max_drawdown_pct']:.1f}% "
|
|
f"2000+={'YES' if metrics['target_met_2000_trades'] else 'no'} profit={'YES' if metrics['target_met_profit'] else 'no'}"
|
|
)
|
|
print(f"Saved {OUT_PATH}")
|
|
finally:
|
|
mt5.shutdown()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|