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>
429 lines
15 KiB
Python
429 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Multi-round elimination audit: small per-strategy tricks vs solo baseline.
|
|
|
|
Round 1 — each trick vs baseline (123.set, one strategy enabled).
|
|
Round 2 — stack non-conflicting WIN tricks from round 1.
|
|
Round 3 — ±15% numeric refine around best single winner.
|
|
|
|
Usage:
|
|
python -m cluster_audit.run_tweak_elimination
|
|
python -m cluster_audit.run_tweak_elimination --only ES --rounds 1
|
|
python -m cluster_audit.run_tweak_elimination --enabled-only --apply
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from itertools import combinations
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from set_parser import parse_set_file
|
|
|
|
from cluster_audit.tweak_manifest import STRATEGY_TWEAKS
|
|
from cluster_audit.united_mt5_manifest import ALL_ENABLE_KEYS, UNITED_MT5_STRATEGIES
|
|
from cluster_audit.united_mt5_runner import (
|
|
BASE_SET,
|
|
CLUSTER,
|
|
deploy_united,
|
|
mt5_context,
|
|
patch_set,
|
|
run_backtest,
|
|
)
|
|
|
|
OUT = Path(__file__).resolve().parent / "reports" / "tweak_elimination"
|
|
CHECKPOINT = OUT / "checkpoint.json"
|
|
|
|
|
|
def solo_overrides(spec: dict) -> dict:
|
|
o: dict = {k: False for k in ALL_ENABLE_KEYS}
|
|
o[spec["enable"]] = True
|
|
o["GAP_Enable"] = False
|
|
o["OPT_GuardOptimizationMode"] = True
|
|
return o
|
|
|
|
|
|
def g(m: dict, k: str) -> float:
|
|
v = m.get(k)
|
|
return float(v) if v is not None else 0.0
|
|
|
|
|
|
def classify(base: dict, var: dict) -> str:
|
|
if not var.get("ready"):
|
|
return "BROKEN"
|
|
if var.get("total_trades", 0) == 0:
|
|
return "NO_TRADES"
|
|
d_net = g(var, "net_profit") - g(base, "net_profit")
|
|
d_sh = g(var, "sharpe") - g(base, "sharpe")
|
|
if d_net > 35 and d_sh >= -0.05:
|
|
return "WIN"
|
|
if d_net < -35 or d_sh < -0.12:
|
|
return "LOSE"
|
|
return "NEUTRAL"
|
|
|
|
|
|
def delta(base: dict, var: dict) -> dict:
|
|
return {
|
|
"net_profit_delta": g(var, "net_profit") - g(base, "net_profit"),
|
|
"sharpe_delta": g(var, "sharpe") - g(base, "sharpe"),
|
|
"pf_delta": g(var, "profit_factor") - g(base, "profit_factor"),
|
|
"trades_delta": int(g(var, "total_trades") - g(base, "total_trades")),
|
|
}
|
|
|
|
|
|
def merge_params(*dicts: dict) -> dict:
|
|
out: dict = {}
|
|
for d in dicts:
|
|
out.update(d)
|
|
return out
|
|
|
|
|
|
def numeric_refine(params: dict, factor: float) -> dict:
|
|
out: dict = {}
|
|
for k, v in params.items():
|
|
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
nv = round(v * factor, 4)
|
|
out[k] = int(nv) if isinstance(v, int) else nv
|
|
else:
|
|
out[k] = v
|
|
return out
|
|
|
|
|
|
def run_variant(ctx: dict, spec: dict, base_ov: dict, extra: dict, tag: str) -> dict:
|
|
ov = {**base_ov, **extra}
|
|
body = patch_set(BASE_SET, ov)
|
|
safe_tag = re.sub(r"[^\w.-]", "_", tag)[:48]
|
|
last: dict = {"ready": False}
|
|
for attempt in range(3):
|
|
if attempt:
|
|
time.sleep(6)
|
|
print(f" retry {attempt} {safe_tag}", flush=True)
|
|
last = run_backtest(
|
|
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
|
body, f"twk_{spec['id']}_{safe_tag}.set", f"twk_{spec['id']}_{safe_tag}",
|
|
)
|
|
if last.get("ready"):
|
|
break
|
|
time.sleep(2)
|
|
return last
|
|
|
|
|
|
def load_checkpoint() -> dict[str, dict]:
|
|
if CHECKPOINT.exists():
|
|
return json.loads(CHECKPOINT.read_text(encoding="utf-8"))
|
|
return {}
|
|
|
|
|
|
def save_checkpoint(all_results: list[dict], *, rounds: int, strategies: list[dict]) -> None:
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
done = {r["id"]: r for r in all_results if "final" in r}
|
|
payload = {
|
|
"updated": datetime.now().isoformat(),
|
|
"rounds": rounds,
|
|
"completed": list(done.keys()),
|
|
"results": all_results,
|
|
}
|
|
CHECKPOINT.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
|
|
summary = {
|
|
"generated": payload["updated"],
|
|
"rounds": rounds,
|
|
"strategies_tested": len(strategies),
|
|
"completed": payload["completed"],
|
|
"applied": [sid for sid, r in done.items() if r.get("final", {}).get("action") == "APPLY"],
|
|
"kept_baseline": [sid for sid, r in done.items() if r.get("final", {}).get("action") == "KEEP_BASELINE"],
|
|
"results": all_results,
|
|
}
|
|
(OUT / "summary.json").write_text(json.dumps(summary, indent=2, default=str), encoding="utf-8")
|
|
|
|
|
|
def round1(ctx: dict, spec: dict, base_ov: dict) -> tuple[dict, list[dict]]:
|
|
sid = spec["id"]
|
|
tricks = STRATEGY_TWEAKS.get(sid, [])
|
|
print(f"\n{'='*60}\n[{sid}] Round 1 — {len(tricks)} tricks\n{'='*60}", flush=True)
|
|
|
|
base = run_variant(ctx, spec, base_ov, {}, "base")
|
|
print(f" BASE net={base.get('net_profit')} sharpe={base.get('sharpe')} "
|
|
f"trades={base.get('total_trades')} ({base.get('elapsed_sec')}s)", flush=True)
|
|
|
|
results: list[dict] = []
|
|
for tw in tricks:
|
|
m = run_variant(ctx, spec, base_ov, tw["params"], tw["name"])
|
|
v = classify(base, m)
|
|
d = delta(base, m)
|
|
row = {
|
|
"round": 1,
|
|
"name": tw["name"],
|
|
"params": tw["params"],
|
|
"verdict": v,
|
|
"delta": d,
|
|
"metrics": m,
|
|
}
|
|
results.append(row)
|
|
print(f" {tw['name']:22s} {v:8s} dNet={d['net_profit_delta']:+.0f} "
|
|
f"dSharpe={d['sharpe_delta']:+.3f} trades={m.get('total_trades')}", flush=True)
|
|
|
|
return base, results
|
|
|
|
|
|
def round2(ctx: dict, spec: dict, base_ov: dict, winners: list[dict]) -> list[dict]:
|
|
if len(winners) < 2:
|
|
return []
|
|
sid = spec["id"]
|
|
print(f" [{sid}] Round 2 — stack {len(winners)} winners", flush=True)
|
|
out: list[dict] = []
|
|
for a, b in combinations(winners, 2):
|
|
keys_a = set(a["params"])
|
|
keys_b = set(b["params"])
|
|
if keys_a & keys_b:
|
|
continue
|
|
combo_name = f"{a['name']}+{b['name']}"
|
|
params = merge_params(a["params"], b["params"])
|
|
m = run_variant(ctx, spec, base_ov, params, combo_name.replace("+", "_"))
|
|
# compare vs best single winner metrics stored in winners
|
|
best_single = max(winners, key=lambda w: g(w["metrics"], "net_profit"))
|
|
v = classify(best_single["metrics"], m)
|
|
d = delta(best_single["metrics"], m)
|
|
row = {
|
|
"round": 2,
|
|
"name": combo_name,
|
|
"params": params,
|
|
"verdict": v,
|
|
"delta_vs_best_single": d,
|
|
"metrics": m,
|
|
}
|
|
out.append(row)
|
|
print(f" {combo_name:30s} {v:8s} dNet={d['net_profit_delta']:+.0f} "
|
|
f"dSharpe={d['sharpe_delta']:+.3f}", flush=True)
|
|
return out
|
|
|
|
|
|
def round3(ctx: dict, spec: dict, base_ov: dict, best: dict) -> list[dict]:
|
|
sid = spec["id"]
|
|
numeric = {k: v for k, v in best["params"].items() if isinstance(v, (int, float))}
|
|
if not numeric:
|
|
return []
|
|
print(f" [{sid}] Round 3 — refine {best['name']}", flush=True)
|
|
out: list[dict] = []
|
|
for fac, label in ((0.85, "refine_lo"), (1.15, "refine_hi")):
|
|
params = merge_params(
|
|
{k: v for k, v in best["params"].items() if k not in numeric},
|
|
numeric_refine(numeric, fac),
|
|
)
|
|
m = run_variant(ctx, spec, base_ov, params, f"{best['name']}_{label}")
|
|
v = classify(best["metrics"], m)
|
|
d = delta(best["metrics"], m)
|
|
out.append({
|
|
"round": 3,
|
|
"name": f"{best['name']}_{label}",
|
|
"params": params,
|
|
"verdict": v,
|
|
"delta_vs_best": d,
|
|
"metrics": m,
|
|
})
|
|
print(f" {label:12s} {v:8s} dNet={d['net_profit_delta']:+.0f} "
|
|
f"dSharpe={d['sharpe_delta']:+.3f}", flush=True)
|
|
return out
|
|
|
|
|
|
def pick_final(base: dict, r1: list[dict], r2: list[dict], r3: list[dict]) -> dict:
|
|
candidates: list[dict] = []
|
|
for r in r1:
|
|
if r["verdict"] == "WIN":
|
|
candidates.append({"source": f"r1:{r['name']}", "params": r["params"], "metrics": r["metrics"]})
|
|
for r in r2:
|
|
if r["verdict"] == "WIN":
|
|
candidates.append({"source": f"r2:{r['name']}", "params": r["params"], "metrics": r["metrics"]})
|
|
for r in r3:
|
|
if r["verdict"] == "WIN":
|
|
candidates.append({"source": f"r3:{r['name']}", "params": r["params"], "metrics": r["metrics"]})
|
|
|
|
if not candidates:
|
|
return {"action": "KEEP_BASELINE", "params": {}, "baseline": base}
|
|
|
|
best = max(candidates, key=lambda c: (g(c["metrics"], "sharpe"), g(c["metrics"], "net_profit")))
|
|
return {
|
|
"action": "APPLY",
|
|
"source": best["source"],
|
|
"params": best["params"],
|
|
"metrics": best["metrics"],
|
|
"baseline": base,
|
|
"improvement": delta(base, best["metrics"]),
|
|
}
|
|
|
|
|
|
def _format_mq5_value(old_val: str, val: object) -> str:
|
|
old = old_val.strip()
|
|
if isinstance(val, bool):
|
|
return "true" if val else "false"
|
|
if isinstance(val, int):
|
|
return str(val)
|
|
if isinstance(val, float):
|
|
if "." in old:
|
|
return f"{val:.1f}" if val == int(val) else str(val)
|
|
return str(int(val)) if val == int(val) else str(val)
|
|
if isinstance(val, str):
|
|
return f'"{val}"' if not (old.startswith('"') and old.endswith('"')) else f'"{val}"'
|
|
return str(val)
|
|
|
|
|
|
def apply_to_mq5_and_set(winners: dict[str, dict]) -> None:
|
|
mq5 = CLUSTER / "main.mq5"
|
|
st = CLUSTER / "123.set"
|
|
text = mq5.read_text(encoding="utf-8")
|
|
set_lines = st.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
n_applied = 0
|
|
|
|
for _sid, w in winners.items():
|
|
if w.get("action") != "APPLY":
|
|
continue
|
|
for key, val in w["params"].items():
|
|
pat = rf"(input\s+(?:bool|int|double|string|ENUM_\w+)\s+{re.escape(key)}\s*=\s*)([^;]+)(;)"
|
|
|
|
def repl(m: re.Match, v: object = val) -> str:
|
|
return f"{m.group(1)}{_format_mq5_value(m.group(2), v)}{m.group(3)}"
|
|
|
|
new_text, n = re.subn(pat, repl, text, count=1)
|
|
if n:
|
|
text = new_text
|
|
n_applied += 1
|
|
else:
|
|
print(f" WARN mq5 miss {key}", flush=True)
|
|
|
|
for i, line in enumerate(set_lines):
|
|
if not line.startswith(f"{key}="):
|
|
continue
|
|
sv = "true" if val is True else "false" if val is False else str(val)
|
|
parts = line.split("||")
|
|
if len(parts) >= 5:
|
|
parts[0] = f"{key}={sv}"
|
|
set_lines[i] = "||".join(parts)
|
|
else:
|
|
set_lines[i] = f"{key}={sv}"
|
|
break
|
|
|
|
if re.search(r'#property version\s+"[\d.]+"', text):
|
|
text = re.sub(r'(#property version\s+)"[\d.]+"', r'\g<1>"1.27"', text, count=1)
|
|
|
|
mq5.write_text(text, encoding="utf-8")
|
|
st.write_text("\n".join(set_lines) + "\n", encoding="utf-8")
|
|
print(f"Applied {n_applied} param updates -> main.mq5 + 123.set", flush=True)
|
|
|
|
|
|
def audit_strategy(ctx: dict, spec: dict, rounds: int) -> dict:
|
|
base_ov = solo_overrides(spec)
|
|
base, r1 = round1(ctx, spec, base_ov)
|
|
winners = [r for r in r1 if r["verdict"] == "WIN"]
|
|
|
|
r2: list[dict] = []
|
|
r3: list[dict] = []
|
|
if rounds >= 2 and len(winners) >= 2:
|
|
r2 = round2(ctx, spec, base_ov, winners)
|
|
winners += [r for r in r2 if r["verdict"] == "WIN"]
|
|
|
|
if rounds >= 3 and winners:
|
|
best = max(winners, key=lambda w: g(w["metrics"], "net_profit"))
|
|
r3 = round3(ctx, spec, base_ov, best)
|
|
winners += [r for r in r3 if r["verdict"] == "WIN"]
|
|
|
|
final = pick_final(base, r1, r2, r3)
|
|
print(f" => {final['action']} {final.get('source', '')} "
|
|
f"dNet={final.get('improvement', {}).get('net_profit_delta', 0):+.0f}", flush=True)
|
|
return {
|
|
"id": spec["id"],
|
|
"name": spec["name"],
|
|
"baseline": base,
|
|
"round1": r1,
|
|
"round2": r2,
|
|
"round3": r3,
|
|
"final": final,
|
|
"eliminated": [r["name"] for r in r1 if r["verdict"] == "LOSE"],
|
|
"neutral": [r["name"] for r in r1 if r["verdict"] == "NEUTRAL"],
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument("--only", default=None)
|
|
p.add_argument("--from", dest="from_id", default=None)
|
|
p.add_argument("--enabled-only", action="store_true")
|
|
p.add_argument("--rounds", type=int, default=3, choices=[1, 2, 3])
|
|
p.add_argument("--apply", action="store_true", help="Write WIN params into main.mq5 + 123.set")
|
|
p.add_argument("--resume", action="store_true", help="Skip strategies already in checkpoint.json")
|
|
args = p.parse_args()
|
|
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
base_params = parse_set_file(BASE_SET)
|
|
|
|
strategies = list(UNITED_MT5_STRATEGIES)
|
|
if args.enabled_only:
|
|
strategies = [
|
|
s for s in strategies
|
|
if base_params.get(s["enable"], type("x", (), {"value": False})).value
|
|
]
|
|
if args.only:
|
|
strategies = [s for s in strategies if s["id"] == args.only]
|
|
elif args.from_id:
|
|
found = False
|
|
filtered = []
|
|
for s in strategies:
|
|
if s["id"] == args.from_id:
|
|
found = True
|
|
if found:
|
|
filtered.append(s)
|
|
strategies = filtered if found else strategies
|
|
|
|
print(f"Tweak elimination | {len(strategies)} strategies | rounds={args.rounds} | base={BASE_SET.name}")
|
|
ctx = mt5_context()
|
|
deploy_united(ctx["data"], ctx["mt5_path"])
|
|
print("Compiled main.ex5 OK", flush=True)
|
|
|
|
all_results: list[dict] = []
|
|
if args.resume and CHECKPOINT.exists():
|
|
ck = json.loads(CHECKPOINT.read_text(encoding="utf-8"))
|
|
all_results = ck.get("results", [])
|
|
done_ids = {r["id"] for r in all_results if "final" in r}
|
|
strategies = [s for s in strategies if s["id"] not in done_ids]
|
|
print(f"Resume: skipping {len(done_ids)} done, {len(strategies)} remaining", flush=True)
|
|
|
|
for spec in strategies:
|
|
try:
|
|
result = audit_strategy(ctx, spec, args.rounds)
|
|
all_results.append(result)
|
|
save_checkpoint(all_results, rounds=args.rounds, strategies=strategies)
|
|
except Exception as ex:
|
|
print(f" ERROR {spec['id']}: {ex}", flush=True)
|
|
all_results.append({"id": spec["id"], "error": str(ex)})
|
|
save_checkpoint(all_results, rounds=args.rounds, strategies=strategies)
|
|
|
|
apply_map = {r["id"]: r["final"] for r in all_results if "final" in r}
|
|
applied = [sid for sid, f in apply_map.items() if f.get("action") == "APPLY"]
|
|
|
|
summary = {
|
|
"generated": datetime.now().isoformat(),
|
|
"rounds": args.rounds,
|
|
"strategies_tested": len(strategies),
|
|
"applied": applied,
|
|
"kept_baseline": [sid for sid, f in apply_map.items() if f.get("action") == "KEEP_BASELINE"],
|
|
"results": all_results,
|
|
}
|
|
out_path = OUT / "summary.json"
|
|
out_path.write_text(json.dumps(summary, indent=2, default=str), encoding="utf-8")
|
|
print(f"\nSaved {out_path}")
|
|
print(f"APPLY ({len(applied)}): {applied}")
|
|
|
|
if args.apply and applied:
|
|
apply_to_mq5_and_set(apply_map)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|