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>
318 lines
9.8 KiB
Python
318 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
MT5 lot-size sweep per production sub-strategy, then combined portfolio.
|
|
|
|
Constraints:
|
|
- ORCH_ReferenceBalance = 3000 (deposit also 3000)
|
|
- Stock lots capped at 15 shares
|
|
|
|
Usage:
|
|
python -m cluster_audit.run_lot_optimize
|
|
python -m cluster_audit.run_lot_optimize --only RS_NVDA
|
|
python -m cluster_audit.run_lot_optimize --apply
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from cluster_audit.united_mt5_manifest import (
|
|
ALL_ENABLE_KEYS,
|
|
HIGH_MARGIN_STOCK_ENABLES,
|
|
LOT_CLASS_BY_ID,
|
|
LOT_GRIDS,
|
|
PRODUCTION_IDS,
|
|
UNITED_MT5_STRATEGIES,
|
|
)
|
|
from cluster_audit.united_mt5_runner import (
|
|
BASE_SET,
|
|
CLUSTER,
|
|
DEPOSIT,
|
|
FROM_DATE,
|
|
TO_DATE,
|
|
deploy_united,
|
|
mt5_context,
|
|
patch_set,
|
|
run_backtest,
|
|
)
|
|
|
|
OUT = Path(__file__).resolve().parent / "reports" / "lot_optimize"
|
|
STOCK_LOT_MAX = 15.0
|
|
REF_BALANCE = 3000.0
|
|
MIN_TRADES = 20
|
|
|
|
|
|
def g(m: dict, k: str) -> float:
|
|
v = m.get(k)
|
|
return float(v) if v is not None else 0.0
|
|
|
|
|
|
def spec_map() -> dict[str, dict]:
|
|
return {s["id"]: s for s in UNITED_MT5_STRATEGIES}
|
|
|
|
|
|
def lot_class(sid: str) -> str:
|
|
return LOT_CLASS_BY_ID.get(sid, "forex")
|
|
|
|
|
|
def lot_grid(sid: str) -> list[float]:
|
|
cls = lot_class(sid)
|
|
grid = list(LOT_GRIDS.get(cls, LOT_GRIDS["forex"]))
|
|
if cls == "stock":
|
|
grid = [x for x in grid if x <= STOCK_LOT_MAX]
|
|
return grid
|
|
|
|
|
|
def score(m: dict) -> float:
|
|
if not m.get("ready"):
|
|
return -1e12
|
|
trades = int(m.get("total_trades") or 0)
|
|
if trades < MIN_TRADES:
|
|
return -1e11 + trades
|
|
net = g(m, "net_profit")
|
|
pf = g(m, "profit_factor")
|
|
sharpe = g(m, "sharpe")
|
|
if net <= 0 or pf < 1.0:
|
|
return -1e10 + net
|
|
return sharpe * 2000.0 + net / 500.0 + pf * 50.0
|
|
|
|
|
|
def common_patches() -> dict[str, float | bool]:
|
|
o: dict[str, float | bool] = {
|
|
"ORCH_ReferenceBalance": REF_BALANCE,
|
|
"ORCH_ScaleLotsByBalance": True,
|
|
"GAP_Enable": False,
|
|
"OPT_GuardOptimizationMode": True,
|
|
}
|
|
for key in HIGH_MARGIN_STOCK_ENABLES:
|
|
o[key] = False
|
|
return o
|
|
|
|
|
|
def production_enables() -> dict[str, bool]:
|
|
prod = set(PRODUCTION_IDS)
|
|
o: dict[str, bool] = {}
|
|
for s in UNITED_MT5_STRATEGIES:
|
|
o[s["enable"]] = s["id"] in prod
|
|
return o
|
|
|
|
|
|
def solo_overrides(spec: dict) -> dict[str, bool]:
|
|
o: dict[str, bool] = {k: False for k in ALL_ENABLE_KEYS}
|
|
o[spec["enable"]] = True
|
|
return o
|
|
|
|
|
|
def apply_lots_to_set_text(text: str, lots: dict[str, float]) -> str:
|
|
lines_out: list[str] = []
|
|
for line in text.splitlines():
|
|
if "=" not in line or line.strip().startswith(";"):
|
|
lines_out.append(line)
|
|
continue
|
|
key = line.split("=", 1)[0].strip()
|
|
if key in lots:
|
|
val = lots[key]
|
|
sval = str(int(val)) if val == int(val) else str(val)
|
|
if "||" in line:
|
|
parts = line.split("||")
|
|
parts[0] = f"{key}={sval}"
|
|
lines_out.append("||".join(parts))
|
|
else:
|
|
lines_out.append(f"{key}={sval}")
|
|
else:
|
|
lines_out.append(line)
|
|
for key, val in lots.items():
|
|
if not any(l.startswith(f"{key}=") for l in lines_out):
|
|
sval = str(int(val)) if val == int(val) else str(val)
|
|
lines_out.append(f"{key}={sval}||{sval}||0||{sval}||N")
|
|
return "\n".join(lines_out) + "\n"
|
|
|
|
|
|
def apply_lots_to_mq5(text: str, lots: dict[str, float]) -> str:
|
|
for key, val in lots.items():
|
|
sval = str(int(val)) if val == int(val) else str(val)
|
|
text, n = re.subn(
|
|
rf"(input double {re.escape(key)} = )[0-9.]+;",
|
|
rf"\g<1>{sval};",
|
|
text,
|
|
count=1,
|
|
)
|
|
if n == 0:
|
|
print(f" warn: {key} not found in main.mq5", flush=True)
|
|
text, n = re.subn(
|
|
r"(input double ORCH_ReferenceBalance = )[0-9.]+;",
|
|
rf"\g<1>{REF_BALANCE};",
|
|
text,
|
|
count=1,
|
|
)
|
|
return text
|
|
|
|
|
|
def optimize_one(ctx: dict, spec: dict, sm: dict[str, dict]) -> dict:
|
|
sid = spec["id"]
|
|
lot_key = spec["lot"]
|
|
grid = lot_grid(sid)
|
|
print(f"\n[{sid}] grid={grid} class={lot_class(sid)}", flush=True)
|
|
|
|
best_lot = grid[0]
|
|
best_m: dict = {"ready": False}
|
|
best_sc = -1e12
|
|
trials: list[dict] = []
|
|
|
|
for lot in grid:
|
|
ov: dict = {**common_patches(), **solo_overrides(spec), lot_key: lot}
|
|
body = patch_set(BASE_SET, ov)
|
|
m = run_backtest(
|
|
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
|
body, f"lot_{sid}_{lot}.set", f"lot_{sid}_{lot}",
|
|
test_symbol=spec.get("test_symbol"),
|
|
)
|
|
sc = score(m)
|
|
trials.append({"lot": lot, "score": sc, "metrics": m})
|
|
print(
|
|
f" lot={lot:6} sc={sc:10.1f} PF={m.get('profit_factor')} "
|
|
f"net={m.get('net_profit')} sharpe={m.get('sharpe')} trades={m.get('total_trades')}",
|
|
flush=True,
|
|
)
|
|
if sc > best_sc:
|
|
best_sc = sc
|
|
best_lot = lot
|
|
best_m = m
|
|
|
|
return {
|
|
"id": sid,
|
|
"lot_key": lot_key,
|
|
"lot_class": lot_class(sid),
|
|
"best_lot": best_lot,
|
|
"best_score": best_sc,
|
|
"best_metrics": best_m,
|
|
"trials": trials,
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument("--from", dest="from_date", default=FROM_DATE)
|
|
p.add_argument("--to", dest="to_date", default=TO_DATE)
|
|
p.add_argument("--only", action="append", default=[], help="Strategy id(s) to optimize")
|
|
p.add_argument("--apply", action="store_true", help="Write best lots to main.mq5 and 123.set")
|
|
args = p.parse_args()
|
|
|
|
import cluster_audit.united_mt5_runner as runner
|
|
|
|
runner.FROM_DATE = args.from_date.replace("-", ".")
|
|
runner.TO_DATE = args.to_date.replace("-", ".")
|
|
runner.DEPOSIT = int(REF_BALANCE)
|
|
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
ctx = mt5_context()
|
|
deploy_united(ctx["data"], ctx["mt5_path"])
|
|
sm = spec_map()
|
|
|
|
ids = args.only if args.only else list(PRODUCTION_IDS)
|
|
print(
|
|
f"Lot optimize deposit={DEPOSIT} ref={REF_BALANCE} "
|
|
f"{runner.FROM_DATE}->{runner.TO_DATE} n={len(ids)}",
|
|
flush=True,
|
|
)
|
|
|
|
results: list[dict] = []
|
|
best_lots: dict[str, float] = {}
|
|
|
|
for sid in ids:
|
|
if sid not in sm:
|
|
print(f"skip unknown id {sid}", flush=True)
|
|
continue
|
|
r = optimize_one(ctx, sm[sid], sm)
|
|
results.append(r)
|
|
best_lots[r["lot_key"]] = r["best_lot"]
|
|
|
|
# Baseline combined (123.set lots + production enables)
|
|
base_ov: dict = {**common_patches(), **production_enables()}
|
|
baseline = run_backtest(
|
|
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
|
patch_set(BASE_SET, base_ov),
|
|
"lot_combined_baseline.set", "lot_combined_baseline",
|
|
)
|
|
print(
|
|
f"\nCOMBINED baseline PF={baseline.get('profit_factor')} net={baseline.get('net_profit')} "
|
|
f"sharpe={baseline.get('sharpe')} trades={baseline.get('total_trades')}",
|
|
flush=True,
|
|
)
|
|
|
|
opt_ov: dict = {**common_patches(), **production_enables(), **best_lots}
|
|
optimized = run_backtest(
|
|
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
|
patch_set(BASE_SET, opt_ov),
|
|
"lot_combined_optimized.set", "lot_combined_optimized",
|
|
)
|
|
print(
|
|
f"COMBINED optimized PF={optimized.get('profit_factor')} net={optimized.get('net_profit')} "
|
|
f"sharpe={optimized.get('sharpe')} trades={optimized.get('total_trades')} "
|
|
f"dNet={g(optimized, 'net_profit') - g(baseline, 'net_profit'):.0f} "
|
|
f"dSharpe={g(optimized, 'sharpe') - g(baseline, 'sharpe'):.3f}",
|
|
flush=True,
|
|
)
|
|
|
|
summary = {
|
|
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
|
"period": {"from": runner.FROM_DATE, "to": runner.TO_DATE},
|
|
"deposit": DEPOSIT,
|
|
"reference_balance": REF_BALANCE,
|
|
"stock_lot_max": STOCK_LOT_MAX,
|
|
"solo": results,
|
|
"best_lots": best_lots,
|
|
"combined_baseline": baseline,
|
|
"combined_optimized": optimized,
|
|
"combined_delta": {
|
|
"net_profit": g(optimized, "net_profit") - g(baseline, "net_profit"),
|
|
"sharpe": g(optimized, "sharpe") - g(baseline, "sharpe"),
|
|
"pf": g(optimized, "profit_factor") - g(baseline, "profit_factor"),
|
|
},
|
|
}
|
|
out_path = OUT / "lot_optimize_summary.json"
|
|
out_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
|
print(f"\nSaved {out_path}", flush=True)
|
|
|
|
print("\nBest lots:", flush=True)
|
|
for sid in ids:
|
|
if sid not in sm:
|
|
continue
|
|
row = next(x for x in results if x["id"] == sid)
|
|
m = row["best_metrics"]
|
|
print(
|
|
f" {sid:12} {row['lot_key']}={row['best_lot']} "
|
|
f"PF={m.get('profit_factor')} sharpe={m.get('sharpe')} net={m.get('net_profit')}",
|
|
flush=True,
|
|
)
|
|
|
|
if args.apply:
|
|
mq5_path = CLUSTER / "main.mq5"
|
|
set_path = CLUSTER / "123.set"
|
|
mq5_path.write_text(apply_lots_to_mq5(mq5_path.read_text(encoding="utf-8"), best_lots), encoding="utf-8")
|
|
set_path.write_text(
|
|
apply_lots_to_set_text(set_path.read_text(encoding="utf-8"), best_lots),
|
|
encoding="utf-8",
|
|
)
|
|
# Ensure reference balance in set
|
|
set_text = set_path.read_text(encoding="utf-8")
|
|
set_text = re.sub(
|
|
r"ORCH_ReferenceBalance=[^\n]+",
|
|
f"ORCH_ReferenceBalance={int(REF_BALANCE)}||{int(REF_BALANCE)}.0||100.000000||10000.000000||N",
|
|
set_text,
|
|
count=1,
|
|
)
|
|
set_path.write_text(set_text, encoding="utf-8")
|
|
print(f"Applied lots to {mq5_path} and {set_path}", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|