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>
This commit is contained in:
co-authored by
Cursor
parent
3f75a08848
commit
605faf5310
@@ -0,0 +1 @@
|
||||
# Cluster audit package
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Shared backtest primitives — fills, costs, metrics, trade log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostModel:
|
||||
spread_points: float = 0.0
|
||||
slippage_points: float = 3.0
|
||||
commission_per_lot: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def for_symbol(cls, symbol: str, slippage: float = 3.0) -> "CostModel":
|
||||
info = mt5.symbol_info(symbol)
|
||||
spread = float(info.spread) if info else 0.0
|
||||
return cls(spread_points=spread, slippage_points=slippage)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trade:
|
||||
side: str
|
||||
open_time: Any
|
||||
close_time: Any
|
||||
open_price: float
|
||||
close_price: float
|
||||
volume: float
|
||||
profit: float
|
||||
bars_held: int = 0
|
||||
exit_reason: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestReport:
|
||||
strategy_id: str
|
||||
symbol: str
|
||||
timeframe: str
|
||||
period_label: str
|
||||
net_profit: float
|
||||
total_trades: int
|
||||
win_rate: float
|
||||
profit_factor: float
|
||||
sharpe: float
|
||||
max_drawdown_pct: float
|
||||
avg_win: float
|
||||
avg_loss: float
|
||||
worst_trades: list[dict]
|
||||
losing_trades: list[dict]
|
||||
exit_reason_breakdown: dict[str, dict[str, float]]
|
||||
monthly_returns: dict[str, float]
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
gross_profit: float = 0.0
|
||||
gross_loss: float = 0.0
|
||||
trades_list: list[Trade] = field(default_factory=list, repr=False)
|
||||
equity_curve: pd.Series | None = field(default=None, repr=False)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"strategy_id": self.strategy_id,
|
||||
"symbol": self.symbol,
|
||||
"timeframe": self.timeframe,
|
||||
"period": self.period_label,
|
||||
"net_profit": self.net_profit,
|
||||
"total_trades": self.total_trades,
|
||||
"win_rate": self.win_rate,
|
||||
"profit_factor": self.profit_factor,
|
||||
"sharpe": self.sharpe,
|
||||
"max_drawdown_pct": self.max_drawdown_pct,
|
||||
"avg_win": self.avg_win,
|
||||
"avg_loss": self.avg_loss,
|
||||
"worst_trades": self.worst_trades,
|
||||
"losing_trades": self.losing_trades,
|
||||
"exit_reason_breakdown": self.exit_reason_breakdown,
|
||||
"monthly_returns": self.monthly_returns,
|
||||
"params": self.params,
|
||||
}
|
||||
|
||||
|
||||
def resolve_symbol(requested: str) -> str:
|
||||
key = requested.split("|")[0].strip()
|
||||
if not key:
|
||||
return requested
|
||||
if mt5.symbol_info(key) is not None:
|
||||
mt5.symbol_select(key, True)
|
||||
return key
|
||||
for suffix in (".NAS", ".NYSE", ".NYS", ".US"):
|
||||
cand = key + suffix
|
||||
if mt5.symbol_info(cand) is not None:
|
||||
mt5.symbol_select(cand, True)
|
||||
return cand
|
||||
for sym in mt5.symbols_get() or []:
|
||||
if sym.name.startswith(key + "."):
|
||||
mt5.symbol_select(sym.name, True)
|
||||
return sym.name
|
||||
return key
|
||||
|
||||
|
||||
def load_bars(symbol: str, tf: int, start, end, trace: bool = False) -> pd.DataFrame:
|
||||
requested = symbol
|
||||
symbol = resolve_symbol(symbol)
|
||||
if trace and symbol != requested:
|
||||
print(f" [load_bars] resolved {requested} -> {symbol}", flush=True)
|
||||
if not mt5.symbol_select(symbol, True):
|
||||
raise RuntimeError(f"Cannot select {symbol}")
|
||||
rates = mt5.copy_rates_range(symbol, tf, start, end)
|
||||
if rates is None or len(rates) < 50:
|
||||
raise RuntimeError(f"No data for {symbol} ({mt5.last_error()})")
|
||||
if trace:
|
||||
print(f" [load_bars] {symbol} got {len(rates)} bars", flush=True)
|
||||
df = pd.DataFrame(rates)
|
||||
df["time"] = pd.to_datetime(df["time"], unit="s")
|
||||
df.set_index("time", inplace=True)
|
||||
return df
|
||||
|
||||
|
||||
def calc_profit(symbol: str, side: str, volume: float, entry: float, exit_px: float) -> float:
|
||||
ot = mt5.ORDER_TYPE_BUY if side == "BUY" else mt5.ORDER_TYPE_SELL
|
||||
p = mt5.order_calc_profit(ot, symbol, volume, entry, exit_px)
|
||||
return float(p) if p is not None else 0.0
|
||||
|
||||
|
||||
def _half_spread(point: float, spread_pts: float) -> float:
|
||||
return spread_pts * point / 2.0
|
||||
|
||||
|
||||
def fill_price(mid: float, point: float, costs: CostModel, side: str, entry: bool) -> float:
|
||||
hs = _half_spread(point, costs.spread_points)
|
||||
slip = costs.slippage_points * point
|
||||
if side == "BUY":
|
||||
return mid + hs + slip if entry else mid - hs - slip
|
||||
return mid - hs - slip if entry else mid + hs + slip
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimState:
|
||||
side: str | None = None
|
||||
entry: float = 0.0
|
||||
entry_i: int = 0
|
||||
entry_time: object = None
|
||||
sl: float = 0.0
|
||||
tp: float = 0.0
|
||||
bars_against: int = 0
|
||||
rsi_against: bool = False
|
||||
|
||||
|
||||
def _bar_seconds(tf_label: str) -> int:
|
||||
mapping = {
|
||||
"M1": 60, "M5": 300, "M10": 600, "M12": 720, "M15": 900, "M20": 1200,
|
||||
"M30": 1800, "H1": 3600, "H2": 7200, "H4": 14400, "D1": 86400,
|
||||
}
|
||||
return mapping.get(tf_label.upper(), 3600)
|
||||
|
||||
|
||||
def run_single_position(
|
||||
df: pd.DataFrame,
|
||||
symbol: str,
|
||||
point: float,
|
||||
costs: CostModel,
|
||||
lot: float,
|
||||
strategy_id: str,
|
||||
tf_label: str,
|
||||
period_label: str,
|
||||
params: dict,
|
||||
initial_balance: float,
|
||||
on_bar,
|
||||
bar_seconds: int | None = None,
|
||||
) -> BacktestReport:
|
||||
"""Single-position bar loop with mark-to-market equity each bar."""
|
||||
bar_sec = bar_seconds or _bar_seconds(tf_label)
|
||||
trades: list[Trade] = []
|
||||
realized_pnl = 0.0
|
||||
equity: list[float] = [initial_balance]
|
||||
st = SimState()
|
||||
|
||||
def close(i: int, mid: float, reason: str) -> None:
|
||||
nonlocal st, realized_pnl
|
||||
if st.side is None:
|
||||
return
|
||||
exit_px = fill_price(mid, point, costs, st.side, entry=False)
|
||||
commission = costs.commission_per_lot * lot * 2.0
|
||||
profit = calc_profit(symbol, st.side, lot, st.entry, exit_px) - commission
|
||||
held = max(1, int((df.index[i] - pd.Timestamp(st.entry_time)).total_seconds() / bar_sec))
|
||||
trades.append(
|
||||
Trade(
|
||||
side=st.side,
|
||||
open_time=st.entry_time,
|
||||
close_time=df.index[i],
|
||||
open_price=st.entry,
|
||||
close_price=exit_px,
|
||||
volume=lot,
|
||||
profit=profit,
|
||||
bars_held=held,
|
||||
exit_reason=reason,
|
||||
)
|
||||
)
|
||||
realized_pnl += profit
|
||||
st = SimState()
|
||||
|
||||
def open_pos(i: int, side: str, mid: float) -> None:
|
||||
nonlocal st
|
||||
st.side = side
|
||||
st.entry = fill_price(mid, point, costs, side, entry=True)
|
||||
st.entry_i = i
|
||||
st.entry_time = df.index[i]
|
||||
|
||||
for i in range(1, len(df)):
|
||||
on_bar(i, st, open_pos, close)
|
||||
bal = initial_balance + realized_pnl
|
||||
if st.side is not None:
|
||||
mark = float(df["close"].iloc[i - 1])
|
||||
bal += calc_profit(symbol, st.side, lot, st.entry, mark)
|
||||
equity.append(bal)
|
||||
|
||||
if st.side is not None:
|
||||
close(len(df) - 1, float(df["close"].iloc[-1]), "eod")
|
||||
|
||||
eq = pd.Series(equity[: len(df)], index=df.index[: len(equity)])
|
||||
report = build_report(strategy_id, symbol, tf_label, period_label, trades, eq, initial_balance, params)
|
||||
report.equity_curve = eq
|
||||
return report
|
||||
|
||||
|
||||
def build_report(
|
||||
strategy_id: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
period_label: str,
|
||||
trades: list[Trade],
|
||||
equity_curve: pd.Series,
|
||||
initial_balance: float,
|
||||
params: dict,
|
||||
) -> BacktestReport:
|
||||
if not trades:
|
||||
return BacktestReport(
|
||||
strategy_id=strategy_id,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
period_label=period_label,
|
||||
net_profit=0.0,
|
||||
total_trades=0,
|
||||
win_rate=0.0,
|
||||
profit_factor=0.0,
|
||||
sharpe=0.0,
|
||||
max_drawdown_pct=0.0,
|
||||
avg_win=0.0,
|
||||
avg_loss=0.0,
|
||||
worst_trades=[],
|
||||
losing_trades=[],
|
||||
exit_reason_breakdown={},
|
||||
monthly_returns={},
|
||||
params=params,
|
||||
)
|
||||
|
||||
profits = [t.profit for t in trades]
|
||||
wins = [p for p in profits if p >= 0]
|
||||
losses = [abs(p) for p in profits if p < 0]
|
||||
gp = sum(wins)
|
||||
gl = sum(losses)
|
||||
net = sum(profits)
|
||||
|
||||
rets = equity_curve.pct_change().dropna()
|
||||
sharpe = 0.0
|
||||
if len(rets) > 10 and rets.std() > 0:
|
||||
bars_per_year = 252 * 24 if "H1" in timeframe else 252 * 24 * 6
|
||||
scale = np.sqrt(bars_per_year / max(len(rets), 1))
|
||||
sharpe = float(rets.mean() / rets.std() * scale)
|
||||
|
||||
peak = equity_curve.cummax()
|
||||
dd = (peak - equity_curve) / peak.replace(0, np.nan)
|
||||
max_dd = float(dd.max()) if len(dd) else 0.0
|
||||
|
||||
monthly = equity_curve.resample("ME").last().pct_change().dropna()
|
||||
monthly_dict = {str(k.date()): float(v) for k, v in monthly.items()}
|
||||
|
||||
def trade_row(t: Trade) -> dict:
|
||||
return {
|
||||
"side": t.side,
|
||||
"open_time": str(t.open_time),
|
||||
"close_time": str(t.close_time),
|
||||
"open_price": t.open_price,
|
||||
"close_price": t.close_price,
|
||||
"profit": t.profit,
|
||||
"bars_held": t.bars_held,
|
||||
"exit_reason": t.exit_reason,
|
||||
}
|
||||
|
||||
sorted_trades = sorted(trades, key=lambda t: t.profit)
|
||||
losers = [trade_row(t) for t in sorted_trades if t.profit < 0]
|
||||
breakdown: dict[str, dict[str, float]] = {}
|
||||
for t in trades:
|
||||
bucket = breakdown.setdefault(t.exit_reason or "?", {"count": 0, "pnl": 0.0, "wins": 0, "losses": 0})
|
||||
bucket["count"] += 1
|
||||
bucket["pnl"] += t.profit
|
||||
if t.profit >= 0:
|
||||
bucket["wins"] += 1
|
||||
else:
|
||||
bucket["losses"] += 1
|
||||
|
||||
return BacktestReport(
|
||||
strategy_id=strategy_id,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
period_label=period_label,
|
||||
net_profit=net,
|
||||
total_trades=len(trades),
|
||||
win_rate=(len(wins) / len(trades) * 100.0) if trades else 0.0,
|
||||
profit_factor=(gp / gl) if gl > 0 else (999.0 if gp > 0 else 0.0),
|
||||
sharpe=sharpe,
|
||||
max_drawdown_pct=max_dd * 100.0,
|
||||
avg_win=(gp / len(wins)) if wins else 0.0,
|
||||
avg_loss=(gl / len(losses)) if losses else 0.0,
|
||||
worst_trades=[trade_row(t) for t in sorted_trades[:5]],
|
||||
losing_trades=losers[:30],
|
||||
exit_reason_breakdown=breakdown,
|
||||
monthly_returns=monthly_dict,
|
||||
params=params,
|
||||
gross_profit=gp,
|
||||
gross_loss=gl,
|
||||
trades_list=list(trades),
|
||||
equity_curve=equity_curve.copy(),
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Per-strategy problem diagnosis and loss tracing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .backtest_core import BacktestReport
|
||||
from .trace_log import TraceLog
|
||||
|
||||
ENGINE_FIX_HINTS = {
|
||||
"rsi_crossover": "trend_strong closes positions in strong trends; high ema_slope/distance thresholds block entries.",
|
||||
"rsi_scalp": "rsi_against exits + spread costs dominate; check OB/OS vs target gap and bars_to_wait.",
|
||||
"rsi_asian": "session window or extreme RSI levels may block entries; verify broker server hour offset.",
|
||||
"mean_reversion": "ADX proxy may differ from MQL iADX; min_ema_distance_pts can block all entries on volatile symbols.",
|
||||
"ema_slope": "needs EMA-cross exit + profit trail even when use_trailing_stop=False; weekly ADX filter missing.",
|
||||
"darvas": "box must be narrow (box_deviation); volume MA filter not yet ported.",
|
||||
"rsi_secret": "zone re-entry in chop; add divergence confirm or widen min_bars_between_trades.",
|
||||
}
|
||||
|
||||
|
||||
def diagnose(spec: dict, baseline: BacktestReport, optimized: BacktestReport | None, log: TraceLog) -> dict[str, Any]:
|
||||
sid = spec["id"]
|
||||
engine = spec["engine"]
|
||||
issues: list[str] = []
|
||||
actions: list[str] = []
|
||||
|
||||
if baseline.total_trades == 0:
|
||||
issues.append("ZERO_TRADES")
|
||||
actions.append("Engine logic or params too strict - compare Python port to MQL defaults.")
|
||||
elif baseline.total_trades < 10:
|
||||
issues.append("LOW_TRADE_COUNT")
|
||||
actions.append("Relax entry filters or widen optimization ranges.")
|
||||
|
||||
if baseline.net_profit < 0:
|
||||
issues.append("NEGATIVE_PNL")
|
||||
if baseline.sharpe < 0:
|
||||
issues.append("NEGATIVE_SHARPE")
|
||||
if baseline.max_drawdown_pct > 25:
|
||||
issues.append("HIGH_DRAWDOWN")
|
||||
|
||||
br = baseline.exit_reason_breakdown
|
||||
loss_reasons = sorted(
|
||||
((k, v["pnl"]) for k, v in br.items() if v["pnl"] < 0),
|
||||
key=lambda x: x[1],
|
||||
)
|
||||
if loss_reasons:
|
||||
top = loss_reasons[0]
|
||||
issues.append(f"TOP_LOSS_REASON:{top[0]}")
|
||||
if top[0] == "rsi_against":
|
||||
actions.append("Widen RSI targets or increase bars_to_wait before rsi_against exit.")
|
||||
elif top[0] == "trend_strong":
|
||||
actions.append("Raise ema_slope/distance thresholds or only block new entries (MQL also force-closes).")
|
||||
elif top[0] == "trail":
|
||||
actions.append("Trail too tight - widen trail_distance_pts or raise activation.")
|
||||
elif top[0] == "sl":
|
||||
actions.append("Stop loss too tight for symbol volatility - scale SL by ATR.")
|
||||
elif top[0] == "hours" or top[0] == "session":
|
||||
actions.append("Trading hours/session filter closing positions — align to broker server time.")
|
||||
elif top[0] == "adx_escape":
|
||||
actions.append("ADX escape fires too early — raise adx_escape threshold.")
|
||||
|
||||
hint = ENGINE_FIX_HINTS.get(engine, "")
|
||||
if hint:
|
||||
actions.append(hint)
|
||||
|
||||
if optimized and optimized is not baseline:
|
||||
from .scoring import DEFAULT_TRADES_PER_DAY, acceptance, min_trades_for_period, period_days, trades_per_day
|
||||
from .strategy_registry import PERIODS
|
||||
|
||||
start, end = PERIODS.get("2021-2026", ("2021-01-01", "2026-06-01"))
|
||||
days = period_days(start, end)
|
||||
min_t = min_trades_for_period(days, DEFAULT_TRADES_PER_DAY)
|
||||
opt_tpd = trades_per_day(optimized, days)
|
||||
base_tpd = trades_per_day(baseline, days)
|
||||
if opt_tpd < DEFAULT_TRADES_PER_DAY:
|
||||
issues.append("LOW_TRADES_PER_DAY")
|
||||
actions.append(
|
||||
f"Only {opt_tpd:.2f} trades/day (need >={DEFAULT_TRADES_PER_DAY:.1f}); "
|
||||
"use lower TF, tighter SL/TP, or relax entry filters."
|
||||
)
|
||||
if optimized.total_trades < min_t and baseline.total_trades >= min_t:
|
||||
issues.append("OPT_COLLAPSED_TRADES")
|
||||
actions.append(
|
||||
f"Optimization cut trades {baseline.total_trades}->{optimized.total_trades} "
|
||||
f"({base_tpd:.2f}->{opt_tpd:.2f}/day)."
|
||||
)
|
||||
opt_ok, opt_issues = acceptance(optimized, days, DEFAULT_TRADES_PER_DAY)
|
||||
if not opt_ok and optimized.net_profit > baseline.net_profit:
|
||||
issues.append("OPT_PROFIT_BUT_FAILS_GATES")
|
||||
actions.append("Higher net but fails gates: " + "; ".join(opt_issues[:4]))
|
||||
if optimized.sharpe > baseline.sharpe + 0.1:
|
||||
issues.append("OPTIMIZATION_HELPED")
|
||||
|
||||
log.banner(f"DIAGNOSIS: {sid}")
|
||||
log.info(f"engine={engine} symbol={baseline.symbol} trades={baseline.total_trades}")
|
||||
if issues:
|
||||
log.warn("issues: " + ", ".join(issues))
|
||||
else:
|
||||
log.info("no critical issues flagged")
|
||||
|
||||
trace_losses(baseline, log, label="baseline")
|
||||
|
||||
if optimized and optimized is not baseline:
|
||||
log.info(
|
||||
f"optimized: net=${optimized.net_profit:.0f} sharpe={optimized.sharpe:.2f} "
|
||||
f"trades={optimized.total_trades}"
|
||||
)
|
||||
if optimized.net_profit < baseline.net_profit:
|
||||
trace_losses(optimized, log, label="optimized", max_rows=10)
|
||||
|
||||
if actions:
|
||||
log.info("suggested actions:")
|
||||
for a in actions[:6]:
|
||||
log.debug(f" - {a}")
|
||||
|
||||
return {
|
||||
"issues": issues,
|
||||
"actions": actions,
|
||||
"top_loss_reasons": loss_reasons[:5],
|
||||
"exit_reason_breakdown": br,
|
||||
}
|
||||
|
||||
|
||||
def trace_losses(report: BacktestReport, log: TraceLog, label: str = "baseline", max_rows: int = 15) -> None:
|
||||
if not report.losing_trades:
|
||||
log.debug(f"{label}: no losing trades")
|
||||
return
|
||||
|
||||
log.info(f"{label} loss trace ({len(report.losing_trades)} losers logged, showing worst {max_rows}):")
|
||||
for i, t in enumerate(report.losing_trades[:max_rows], 1):
|
||||
log.info(
|
||||
f" #{i:02d} {t['side']:4} ${t['profit']:8.2f} {t['exit_reason']:12} "
|
||||
f"bars={t['bars_held']:4} {t['open_time']} -> {t['close_time']}"
|
||||
)
|
||||
|
||||
if report.exit_reason_breakdown:
|
||||
log.debug(f"{label} exit reason PnL:")
|
||||
for reason, stats in sorted(
|
||||
report.exit_reason_breakdown.items(),
|
||||
key=lambda x: x[1]["pnl"],
|
||||
):
|
||||
log.debug(
|
||||
f" {reason:14} count={int(stats['count']):4} "
|
||||
f"wins={int(stats['wins']):3} losses={int(stats['losses']):3} pnl=${stats['pnl']:.0f}"
|
||||
)
|
||||
@@ -0,0 +1,882 @@
|
||||
"""Python ports of SuperEA engines for cluster audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from indicator_utils import calculate_adx, calculate_atr, calculate_dmi, calculate_ema, calculate_rsi
|
||||
|
||||
from .backtest_core import (
|
||||
BacktestReport,
|
||||
CostModel,
|
||||
Trade,
|
||||
build_report,
|
||||
calc_profit,
|
||||
fill_price,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimState:
|
||||
side: str | None = None
|
||||
entry: float = 0.0
|
||||
entry_i: int = 0
|
||||
entry_time: Any = None
|
||||
sl: float = 0.0
|
||||
tp: float = 0.0
|
||||
bars_against: int = 0
|
||||
rsi_against: bool = False
|
||||
|
||||
|
||||
def _run_single_position(
|
||||
df: pd.DataFrame,
|
||||
symbol: str,
|
||||
point: float,
|
||||
costs: CostModel,
|
||||
lot: float,
|
||||
strategy_id: str,
|
||||
tf_label: str,
|
||||
period_label: str,
|
||||
params: dict,
|
||||
initial_balance: float,
|
||||
on_bar,
|
||||
) -> BacktestReport:
|
||||
trades: list[Trade] = []
|
||||
equity = [initial_balance]
|
||||
st = SimState()
|
||||
|
||||
def close(i: int, mid: float, reason: str) -> None:
|
||||
nonlocal st
|
||||
if st.side is None:
|
||||
return
|
||||
exit_px = fill_price(mid, point, costs, st.side, entry=False)
|
||||
commission = costs.commission_per_lot * lot * 2
|
||||
profit = calc_profit(symbol, st.side, lot, st.entry, exit_px) - commission
|
||||
trades.append(
|
||||
Trade(
|
||||
side=st.side,
|
||||
open_time=st.entry_time,
|
||||
close_time=df.index[i],
|
||||
open_price=st.entry,
|
||||
close_price=exit_px,
|
||||
volume=lot,
|
||||
profit=profit,
|
||||
bars_held=i - st.entry_i,
|
||||
exit_reason=reason,
|
||||
)
|
||||
)
|
||||
equity.append(equity[-1] + profit)
|
||||
st = SimState()
|
||||
|
||||
def open_pos(i: int, side: str, mid: float) -> None:
|
||||
nonlocal st
|
||||
st.side = side
|
||||
st.entry = fill_price(mid, point, costs, side, entry=True)
|
||||
st.entry_i = i
|
||||
st.entry_time = df.index[i]
|
||||
st.sl = 0.0
|
||||
st.tp = 0.0
|
||||
st.bars_against = 0
|
||||
st.rsi_against = False
|
||||
|
||||
for i in range(1, len(df)):
|
||||
mid = float(df["open"].iloc[i])
|
||||
on_bar(i, st, open_pos, close)
|
||||
if len(equity) == len(trades) + 1:
|
||||
equity.append(equity[-1])
|
||||
|
||||
if st.side is not None:
|
||||
close(len(df) - 1, float(df["close"].iloc[-1]), "eod")
|
||||
|
||||
eq = pd.Series(equity[: len(df)], index=df.index[: len(equity)])
|
||||
return build_report(strategy_id, symbol, tf_label, period_label, trades, eq, initial_balance, params)
|
||||
|
||||
|
||||
# --- RSI Scalping ---
|
||||
def backtest_rsi_scalp(
|
||||
df: pd.DataFrame,
|
||||
symbol: str,
|
||||
period_label: str,
|
||||
strategy_id: str,
|
||||
params: dict,
|
||||
lot: float = 0.1,
|
||||
costs: CostModel | None = None,
|
||||
) -> BacktestReport:
|
||||
info = __import__("MetaTrader5").symbol_info(symbol)
|
||||
point = float(info.point) if info else 0.01
|
||||
costs = costs or CostModel.for_symbol(symbol)
|
||||
rsi = calculate_rsi(df["close"], int(params["rsi_period"])).to_numpy()
|
||||
atr = calculate_atr(df, int(params.get("reversal_atr_period", 14))).to_numpy()
|
||||
trail_dist = params.get("trail_distance_pts", 0) * point
|
||||
trail_act = (params.get("trail_activation_pts") or params.get("trail_distance_pts", 0)) * point
|
||||
use_rsi_against = params.get("use_rsi_against_exit", True)
|
||||
max_adv_atr = float(params.get("max_adverse_atr", 0))
|
||||
|
||||
def on_bar(i, st, open_pos, close):
|
||||
if i < 3 or np.isnan(rsi[i - 1]):
|
||||
return
|
||||
sig, prev, two = rsi[i - 1], rsi[i - 2], rsi[i - 3]
|
||||
mid = float(df["open"].iloc[i])
|
||||
hi, lo = float(df["high"].iloc[i]), float(df["low"].iloc[i])
|
||||
|
||||
if st.side is not None and params.get("use_reversal_escape"):
|
||||
a = float(atr[i - 1]) if not np.isnan(atr[i - 1]) else 0.0
|
||||
if a > 0:
|
||||
adv_mult = float(params.get("reversal_adverse_atr_mult", 1.5))
|
||||
rsi_vel = float(params.get("reversal_rsi_velocity", 8.0))
|
||||
need = int(params.get("reversal_signs_required", 2))
|
||||
signs = 0
|
||||
if st.side == "BUY":
|
||||
if st.entry - lo >= adv_mult * a:
|
||||
signs += 1
|
||||
if sig - prev >= rsi_vel:
|
||||
signs += 1
|
||||
else:
|
||||
if hi - st.entry >= adv_mult * a:
|
||||
signs += 1
|
||||
if sig - prev >= rsi_vel:
|
||||
signs += 1
|
||||
if signs >= need:
|
||||
close(i, mid, "reversal_escape")
|
||||
return
|
||||
|
||||
if st.side is not None and params.get("use_trailing") and trail_dist > 0:
|
||||
if st.side == "BUY":
|
||||
bid = float(df["close"].iloc[i])
|
||||
if bid - st.entry > trail_act:
|
||||
nsl = bid - trail_dist
|
||||
if nsl > st.sl:
|
||||
st.sl = nsl
|
||||
if st.sl > 0 and lo <= st.sl:
|
||||
close(i, st.sl, "trail")
|
||||
return
|
||||
else:
|
||||
ask = float(df["close"].iloc[i])
|
||||
if st.entry - ask > trail_act:
|
||||
nsl = ask + trail_dist
|
||||
if st.sl == 0 or nsl < st.sl:
|
||||
st.sl = nsl
|
||||
if st.sl > 0 and hi >= st.sl:
|
||||
close(i, st.sl, "trail")
|
||||
return
|
||||
|
||||
if st.side is not None and max_adv_atr > 0:
|
||||
a = float(atr[i - 1]) if not np.isnan(atr[i - 1]) else 0.0
|
||||
if a > 0:
|
||||
if st.side == "BUY" and (st.entry - lo) / a >= max_adv_atr:
|
||||
close(i, mid, "adverse_atr")
|
||||
return
|
||||
if st.side == "SELL" and (hi - st.entry) / a >= max_adv_atr:
|
||||
close(i, mid, "adverse_atr")
|
||||
return
|
||||
|
||||
if st.side == "BUY":
|
||||
if use_rsi_against and sig < params["rsi_oversold"]:
|
||||
st.bars_against = st.bars_against + 1 if st.rsi_against else 1
|
||||
st.rsi_against = True
|
||||
if st.bars_against >= params["bars_to_wait"]:
|
||||
close(i, mid, "rsi_against")
|
||||
else:
|
||||
st.rsi_against = False
|
||||
st.bars_against = 0
|
||||
if sig >= params["rsi_target_buy"]:
|
||||
close(i, mid, "target")
|
||||
elif st.side == "SELL":
|
||||
if use_rsi_against and sig > params["rsi_overbought"]:
|
||||
st.bars_against = st.bars_against + 1 if st.rsi_against else 1
|
||||
st.rsi_against = True
|
||||
if st.bars_against >= params["bars_to_wait"]:
|
||||
close(i, mid, "rsi_against")
|
||||
else:
|
||||
st.rsi_against = False
|
||||
st.bars_against = 0
|
||||
if sig <= params["rsi_target_sell"]:
|
||||
close(i, mid, "target")
|
||||
else:
|
||||
if two <= params["rsi_oversold"] and prev > params["rsi_oversold"]:
|
||||
open_pos(i, "BUY", mid)
|
||||
elif two >= params["rsi_overbought"] and prev < params["rsi_overbought"]:
|
||||
min_depth = float(params.get("min_ob_depth", 0))
|
||||
if two < params["rsi_overbought"] + min_depth:
|
||||
pass
|
||||
else:
|
||||
skip_h = int(params.get("skip_short_hour_after", 24))
|
||||
if df.index[i].hour < skip_h:
|
||||
open_pos(i, "SELL", mid)
|
||||
|
||||
return _run_single_position(
|
||||
df, symbol, point, costs, lot, strategy_id, params.get("tf", "H1"),
|
||||
period_label, params, 10_000.0, on_bar,
|
||||
)
|
||||
|
||||
|
||||
# --- RSI CrossOver ---
|
||||
def _price_to_ema_pips(symbol: str, close: float, ema: float) -> float:
|
||||
info = __import__("MetaTrader5").symbol_info(symbol)
|
||||
if info is None:
|
||||
return abs(close - ema) * 10.0
|
||||
point = float(info.point)
|
||||
digits = int(info.digits)
|
||||
pip_mult = 10.0 if digits in (3, 5) else 1.0
|
||||
pip_size = point * pip_mult if point > 0 else point
|
||||
return abs(close - ema) / pip_size if pip_size > 0 else 0.0
|
||||
|
||||
|
||||
def backtest_rsi_crossover(df, symbol, period_label, strategy_id, params, lot=0.1, costs=None):
|
||||
info = __import__("MetaTrader5").symbol_info(symbol)
|
||||
point = float(info.point) if info else 0.01
|
||||
costs = costs or CostModel.for_symbol(symbol)
|
||||
rsi = calculate_rsi(df["close"], int(params["rsi_period"])).to_numpy()
|
||||
ema = calculate_ema(df["close"], int(params["ema_period"])).to_numpy()
|
||||
trail = params.get("trailing_stop_pts", 0) * point
|
||||
prev_rsi_state = 0.0
|
||||
last_trade_i = -10_000
|
||||
cooldown_bars = max(1, int(params.get("cooldown_seconds", 300) / 3600))
|
||||
use_trend_filter = params.get("use_trend_strength_filter", True)
|
||||
|
||||
weekday_ok = {
|
||||
0: params.get("sunday", False),
|
||||
1: params.get("monday", False),
|
||||
2: params.get("tuesday", True),
|
||||
3: params.get("wednesday", True),
|
||||
4: params.get("thursday", True),
|
||||
5: params.get("friday", False),
|
||||
6: params.get("saturday", False),
|
||||
}
|
||||
|
||||
def hours_ok(ts) -> bool:
|
||||
h = ts.hour
|
||||
def in_win(begin: int, end: int) -> bool:
|
||||
b, e = begin % 24, end % 24
|
||||
if b == e:
|
||||
return False
|
||||
if b < e:
|
||||
return b <= h < e
|
||||
return h >= b or h < e
|
||||
return in_win(params.get("trading_hour_one_begin", 0), params.get("trading_hour_one_end", 22)) or in_win(
|
||||
params.get("trading_hour_two_begin", 6), params.get("trading_hour_two_end", 19)
|
||||
)
|
||||
|
||||
def on_bar(i, st, open_pos, close):
|
||||
nonlocal prev_rsi_state, last_trade_i
|
||||
if i < 3 or np.isnan(rsi[i - 1]) or np.isnan(ema[i - 1]):
|
||||
return
|
||||
ts = df.index[i]
|
||||
if not weekday_ok.get(ts.weekday(), False) or not hours_ok(ts):
|
||||
if st.side:
|
||||
close(i, float(df["open"].iloc[i]), "hours")
|
||||
return
|
||||
|
||||
cur = rsi[i - 1]
|
||||
if prev_rsi_state == 0.0:
|
||||
prev_rsi_state = cur
|
||||
return
|
||||
|
||||
ema_slope = (ema[i - 1] - ema[i - 2]) * 100.0
|
||||
price_to_ema = abs((float(df["close"].iloc[i - 1]) - ema[i - 1]) * 10.0)
|
||||
slope_th = float(params.get("ema_slope_threshold", 100))
|
||||
dist_th = float(params.get("ema_distance_threshold", 100))
|
||||
trend_strong = use_trend_filter and (
|
||||
(slope_th > 0 and abs(ema_slope) > slope_th)
|
||||
or (dist_th > 0 and price_to_ema > dist_th)
|
||||
)
|
||||
|
||||
mid = float(df["open"].iloc[i])
|
||||
if st.side == "BUY" and trail > 0:
|
||||
bid = float(df["close"].iloc[i])
|
||||
if bid - st.entry > trail:
|
||||
st.sl = max(st.sl, bid - trail)
|
||||
if st.sl > 0 and float(df["low"].iloc[i]) <= st.sl:
|
||||
close(i, st.sl, "trail")
|
||||
prev_rsi_state = cur
|
||||
return
|
||||
if st.side == "SELL" and trail > 0:
|
||||
ask = float(df["close"].iloc[i])
|
||||
if st.entry - ask > trail:
|
||||
st.sl = ask + trail if st.sl == 0 else min(st.sl, ask + trail)
|
||||
if st.sl > 0 and float(df["high"].iloc[i]) >= st.sl:
|
||||
close(i, st.sl, "trail")
|
||||
prev_rsi_state = cur
|
||||
return
|
||||
|
||||
if st.side == "BUY" and cur > params.get("exit_buy_rsi", 80):
|
||||
close(i, mid, "exit_rsi")
|
||||
elif st.side == "SELL" and cur < params.get("exit_sell_rsi", 20):
|
||||
close(i, mid, "exit_rsi")
|
||||
elif trend_strong and st.side:
|
||||
close(i, mid, "trend_strong")
|
||||
elif not st.side and not trend_strong and i - last_trade_i >= cooldown_bars:
|
||||
ob = params.get("overbought_level", 70)
|
||||
os = params.get("oversold_level", 30)
|
||||
sell_spread = params.get("entry_rsi_sell_spread", 0)
|
||||
buy_spread = params.get("entry_rsi_buy_spread", 0)
|
||||
if prev_rsi_state >= ob and cur < ob - sell_spread:
|
||||
open_pos(i, "SELL", mid)
|
||||
last_trade_i = i
|
||||
elif prev_rsi_state <= os and cur > os + buy_spread:
|
||||
open_pos(i, "BUY", mid)
|
||||
last_trade_i = i
|
||||
prev_rsi_state = cur
|
||||
|
||||
return _run_single_position(
|
||||
df, symbol, point, costs, lot, strategy_id, "H1", period_label, params, 10_000.0, on_bar,
|
||||
)
|
||||
|
||||
|
||||
# --- RSI Asian ---
|
||||
def backtest_rsi_asian(df, symbol, period_label, strategy_id, params, lot=0.1, costs=None):
|
||||
info = __import__("MetaTrader5").symbol_info(symbol)
|
||||
point = float(info.point) if info else 0.01
|
||||
costs = costs or CostModel.for_symbol(symbol)
|
||||
rsi = calculate_rsi(df["close"], int(params["rsi_period"])).to_numpy()
|
||||
sess_start = params.get("asian_session_start", 0)
|
||||
sess_end = params.get("asian_session_end", 8)
|
||||
|
||||
def in_session(ts) -> bool:
|
||||
return sess_start <= ts.hour < sess_end
|
||||
|
||||
def on_bar(i, st, open_pos, close):
|
||||
if i < 2 or np.isnan(rsi[i - 1]):
|
||||
return
|
||||
ts = df.index[i]
|
||||
prev, cur = rsi[i - 2], rsi[i - 1]
|
||||
mid = float(df["open"].iloc[i])
|
||||
|
||||
if st.side and params.get("close_outside_session") and not in_session(ts):
|
||||
close(i, mid, "session")
|
||||
return
|
||||
if st.side and params.get("use_rsi_exit"):
|
||||
exit_lvl = params.get("rsi_exit_level", 55)
|
||||
if (prev < exit_lvl <= cur) or (prev > exit_lvl >= cur):
|
||||
close(i, mid, "rsi_exit")
|
||||
|
||||
if not in_session(ts):
|
||||
return
|
||||
if st.side:
|
||||
return
|
||||
if prev < params["overbought_level"] <= cur:
|
||||
open_pos(i, "SELL", mid)
|
||||
elif prev > params["oversold_level"] >= cur:
|
||||
open_pos(i, "BUY", mid)
|
||||
|
||||
return _run_single_position(
|
||||
df, symbol, point, costs, lot, strategy_id, "M15", period_label, params, 10_000.0, on_bar,
|
||||
)
|
||||
|
||||
|
||||
# --- Mean Reversion ---
|
||||
def backtest_mean_reversion(df, symbol, period_label, strategy_id, params, lot=0.1, costs=None):
|
||||
info = __import__("MetaTrader5").symbol_info(symbol)
|
||||
point = float(info.point) if info else 0.01
|
||||
costs = costs or CostModel.for_symbol(symbol)
|
||||
rsi = calculate_rsi(df["close"], int(params["rsi_period"])).to_numpy()
|
||||
ema = calculate_ema(df["close"], int(params["ema_period"])).to_numpy()
|
||||
adx = calculate_adx(df, int(params.get("adx_period", 14))).to_numpy()
|
||||
|
||||
def on_bar(i, st, open_pos, close):
|
||||
if i < max(params["ema_period"], 20) + 2:
|
||||
return
|
||||
if np.isnan(rsi[i - 1]) or np.isnan(ema[i - 1]) or np.isnan(adx[i - 1]):
|
||||
return
|
||||
mid = float(df["open"].iloc[i])
|
||||
cls = float(df["close"].iloc[i - 1])
|
||||
dist_buy = (ema[i - 1] - cls) / point
|
||||
dist_sell = (cls - ema[i - 1]) / point
|
||||
adx_v = float(adx[i - 1])
|
||||
|
||||
if st.side:
|
||||
adx_now = float(adx[i - 1]) if not np.isnan(adx[i - 1]) else 0.0
|
||||
if adx_now >= params.get("adx_escape", 30):
|
||||
close(i, mid, "adx_escape")
|
||||
elif params.get("use_hard_sltp"):
|
||||
if st.side == "BUY":
|
||||
if cls <= st.entry - params.get("sl_points", 0) * point:
|
||||
close(i, mid, "sl")
|
||||
elif cls >= st.entry + params.get("tp_points", 0) * point:
|
||||
close(i, mid, "tp")
|
||||
else:
|
||||
if cls >= st.entry + params.get("sl_points", 0) * point:
|
||||
close(i, mid, "sl")
|
||||
elif cls <= st.entry - params.get("tp_points", 0) * point:
|
||||
close(i, mid, "tp")
|
||||
return
|
||||
|
||||
if adx_v <= 0 or adx_v >= params.get("adx_max_for_entry", 20):
|
||||
return
|
||||
use_cross = params.get("use_rsi_cross", True)
|
||||
if use_cross:
|
||||
buy_rsi = rsi[i - 2] > params["rsi_oversold"] >= rsi[i - 1]
|
||||
sell_rsi = rsi[i - 2] < params["rsi_overbought"] <= rsi[i - 1]
|
||||
else:
|
||||
buy_rsi = rsi[i - 1] <= params["rsi_oversold"]
|
||||
sell_rsi = rsi[i - 1] >= params["rsi_overbought"]
|
||||
if buy_rsi and dist_buy >= params.get("min_ema_distance_pts", 0):
|
||||
open_pos(i, "BUY", mid)
|
||||
if params.get("use_hard_sltp"):
|
||||
st.sl = st.entry - params.get("sl_points", 0) * point
|
||||
st.tp = st.entry + params.get("tp_points", 0) * point
|
||||
elif sell_rsi and dist_sell >= params.get("min_ema_distance_pts", 0):
|
||||
open_pos(i, "SELL", mid)
|
||||
if params.get("use_hard_sltp"):
|
||||
st.sl = st.entry + params.get("sl_points", 0) * point
|
||||
st.tp = st.entry - params.get("tp_points", 0) * point
|
||||
|
||||
return _run_single_position(
|
||||
df, symbol, point, costs, lot, strategy_id, "M15", period_label, params, 10_000.0, on_bar,
|
||||
)
|
||||
|
||||
|
||||
# --- EMA Slope (monitor + crossover state machine, matches MQL) ---
|
||||
def _weekly_dmi_lookup(df: pd.DataFrame, period: int, bar_shift: int) -> tuple[pd.Series, pd.Series, pd.Series]:
|
||||
wdf = df.resample("W-FRI").agg({"high": "max", "low": "min", "close": "last"}).dropna()
|
||||
dmi = calculate_dmi(wdf, period)
|
||||
shift = max(0, bar_shift)
|
||||
adx = dmi["adx"].shift(shift).reindex(df.index, method="ffill")
|
||||
plus = dmi["plus_di"].shift(shift).reindex(df.index, method="ffill")
|
||||
minus = dmi["minus_di"].shift(shift).reindex(df.index, method="ffill")
|
||||
return adx, plus, minus
|
||||
|
||||
|
||||
def backtest_ema_slope(df, symbol, period_label, strategy_id, params, lot=0.1, costs=None):
|
||||
info = __import__("MetaTrader5").symbol_info(symbol)
|
||||
point = float(info.point) if info else 0.01
|
||||
costs = costs or CostModel.for_symbol(symbol)
|
||||
ema_period = int(params["ema_period"])
|
||||
ema = calculate_ema(df["close"], ema_period).to_numpy()
|
||||
atr = calculate_atr(df, 14).to_numpy()
|
||||
closes = df["close"].to_numpy()
|
||||
opens = df["open"].to_numpy()
|
||||
highs = df["high"].to_numpy()
|
||||
lows = df["low"].to_numpy()
|
||||
times = df.index
|
||||
mult = 10.0 if ("XAU" in symbol or "BTC" in symbol) else 1.0
|
||||
|
||||
w_adx, w_plus, w_minus = _weekly_dmi_lookup(
|
||||
df, int(params.get("weekly_adx_period", 28)), int(params.get("weekly_adx_bar_shift", 8))
|
||||
)
|
||||
|
||||
price_trigger_active = False
|
||||
slope_trigger_active = False
|
||||
monitor_active = False
|
||||
monitor_start_i = -1
|
||||
trades_in_cross = 0
|
||||
last_close = 0.0
|
||||
last_ema = 0.0
|
||||
last_bar_time = None
|
||||
|
||||
def weekly_ok(i: int, side: str) -> bool:
|
||||
if not params.get("use_weekly_adx_filter", True):
|
||||
return True
|
||||
adx_v = float(w_adx.iloc[i - 1]) if i > 0 else np.nan
|
||||
if np.isnan(adx_v) or adx_v < params.get("weekly_adx_min", 25):
|
||||
return False
|
||||
if not params.get("weekly_adx_use_direction", True):
|
||||
return True
|
||||
pdi = float(w_plus.iloc[i - 1])
|
||||
mdi = float(w_minus.iloc[i - 1])
|
||||
if side == "BUY":
|
||||
return pdi > mdi
|
||||
return mdi > pdi
|
||||
|
||||
def on_bar(i, st, open_pos, close):
|
||||
nonlocal price_trigger_active, slope_trigger_active, monitor_active, monitor_start_i
|
||||
nonlocal trades_in_cross, last_close, last_ema, last_bar_time
|
||||
if i < ema_period + 3 or np.isnan(ema[i - 1]) or np.isnan(ema[i - 2]):
|
||||
return
|
||||
|
||||
if params.get("use_bar_data", True):
|
||||
ts = times[i]
|
||||
if last_bar_time is not None and ts == last_bar_time:
|
||||
return
|
||||
last_bar_time = ts
|
||||
|
||||
mid = float(opens[i])
|
||||
bar_close = float(closes[i - 1])
|
||||
ema_now = float(ema[i - 1])
|
||||
ema_prev = float(ema[i - 2])
|
||||
|
||||
if last_close != 0.0:
|
||||
if (last_close <= last_ema and bar_close > ema_now) or (last_close >= last_ema and bar_close < ema_now):
|
||||
trades_in_cross = 0
|
||||
last_close, last_ema = bar_close, ema_now
|
||||
|
||||
price_dist = abs(bar_close - ema_now) / point / mult
|
||||
if price_dist > params.get("price_threshold_pips", 100) and not price_trigger_active:
|
||||
price_trigger_active = True
|
||||
slope = (ema_now - ema_prev) / point / mult
|
||||
if abs(slope) > params.get("slope_threshold_pips", 20) and not slope_trigger_active:
|
||||
slope_trigger_active = True
|
||||
|
||||
if price_trigger_active and slope_trigger_active and not monitor_active:
|
||||
monitor_active = True
|
||||
monitor_start_i = i
|
||||
|
||||
tf_sec = 3600
|
||||
timeout_bars = int(params.get("monitor_timeout_sec", 340) / tf_sec)
|
||||
if monitor_active and monitor_start_i >= 0 and (i - monitor_start_i) > timeout_bars:
|
||||
monitor_active = False
|
||||
price_trigger_active = False
|
||||
slope_trigger_active = False
|
||||
|
||||
if st.side:
|
||||
bars_open = i - st.entry_i
|
||||
bar_close_now = float(closes[i])
|
||||
a = float(atr[i - 1]) if not np.isnan(atr[i - 1]) else 0.0
|
||||
max_loss_atr = float(params.get("max_loss_atr", 2.0))
|
||||
if a > 0 and max_loss_atr > 0:
|
||||
if st.side == "BUY" and float(lows[i]) <= st.entry - max_loss_atr * a:
|
||||
close(i, st.entry - max_loss_atr * a, "atr_sl")
|
||||
return
|
||||
if st.side == "SELL" and float(highs[i]) >= st.entry + max_loss_atr * a:
|
||||
close(i, st.entry + max_loss_atr * a, "atr_sl")
|
||||
return
|
||||
|
||||
trail_pips = params.get("trailing_stop_pips", 50)
|
||||
trail_px = trail_pips * point * mult
|
||||
bar_close_now = float(closes[i])
|
||||
in_profit = (bar_close_now > st.entry) if st.side == "BUY" else (st.entry > bar_close_now)
|
||||
use_trail = params.get("use_trailing_stop", False)
|
||||
trail_act = params.get("trailing_activation_pips", 0)
|
||||
trail_ready = in_profit if trail_act <= 0 else (
|
||||
(bar_close_now - st.entry) / point / mult >= trail_act
|
||||
if st.side == "BUY"
|
||||
else (st.entry - bar_close_now) / point / mult >= trail_act
|
||||
)
|
||||
if trail_pips > 0 and (use_trail or in_profit) and trail_ready:
|
||||
if st.side == "BUY":
|
||||
st.sl = max(st.sl, bar_close_now - trail_px)
|
||||
if st.sl > 0 and float(lows[i]) <= st.sl:
|
||||
close(i, st.sl, "trail")
|
||||
return
|
||||
else:
|
||||
st.sl = bar_close_now + trail_px if st.sl <= 0 else min(st.sl, bar_close_now + trail_px)
|
||||
if st.sl > 0 and float(highs[i]) >= st.sl:
|
||||
close(i, st.sl, "trail")
|
||||
return
|
||||
|
||||
ema_exit = (st.side == "BUY" and bar_close_now < ema_now) or (
|
||||
st.side == "SELL" and bar_close_now > ema_now
|
||||
)
|
||||
unrealized = calc_profit(symbol, st.side, lot, st.entry, bar_close_now)
|
||||
if ema_exit and unrealized > 0:
|
||||
close(i, mid, "ema_cross")
|
||||
return
|
||||
|
||||
if params.get("close_unprofitable_trades", True):
|
||||
check_bars = int(params.get("profit_check_bars", 78))
|
||||
if bars_open >= check_bars:
|
||||
unrealized = calc_profit(symbol, st.side, lot, st.entry, bar_close_now)
|
||||
if unrealized <= 0:
|
||||
close(i, mid, "unprofitable")
|
||||
return
|
||||
return
|
||||
|
||||
if not monitor_active:
|
||||
return
|
||||
if trades_in_cross >= params.get("max_trades_per_crossover", 5):
|
||||
return
|
||||
|
||||
if bar_close > ema_now and weekly_ok(i, "BUY"):
|
||||
open_pos(i, "BUY", mid)
|
||||
trades_in_cross += 1
|
||||
monitor_active = False
|
||||
price_trigger_active = False
|
||||
slope_trigger_active = False
|
||||
elif bar_close < ema_now and weekly_ok(i, "SELL"):
|
||||
open_pos(i, "SELL", mid)
|
||||
trades_in_cross += 1
|
||||
monitor_active = False
|
||||
price_trigger_active = False
|
||||
slope_trigger_active = False
|
||||
|
||||
return _run_single_position(
|
||||
df, symbol, point, costs, lot, strategy_id, "H1", period_label, params, 10_000.0, on_bar,
|
||||
)
|
||||
|
||||
|
||||
# --- Darvas Box (matches MQL: narrow box + breakout + trend strength) ---
|
||||
def backtest_darvas(df, symbol, period_label, strategy_id, params, lot=0.1, costs=None):
|
||||
info = __import__("MetaTrader5").symbol_info(symbol)
|
||||
point = float(info.point) if info else 0.01
|
||||
costs = costs or CostModel.for_symbol(symbol)
|
||||
period = int(params.get("box_period", 165))
|
||||
box_dev = float(params.get("box_deviation", 30000))
|
||||
trend_thresh = float(params.get("trend_threshold", 4.94))
|
||||
ma_period = int(params.get("ma_period", 125))
|
||||
sl_pts = float(params.get("stop_loss_pts", 1665))
|
||||
tp_pts = float(params.get("take_profit_pts", 3685))
|
||||
vol_thresh = int(params.get("volume_threshold", 0))
|
||||
max_range = box_dev * point
|
||||
|
||||
ma = calculate_ema(df["close"], ma_period).to_numpy()
|
||||
highs = df["high"].to_numpy()
|
||||
lows = df["low"].to_numpy()
|
||||
opens = df["open"].to_numpy()
|
||||
closes = df["close"].to_numpy()
|
||||
vols = df["tick_volume"].to_numpy() if "tick_volume" in df.columns else np.zeros(len(df))
|
||||
|
||||
def on_bar(i, st, open_pos, close):
|
||||
if i < period + ma_period + 2:
|
||||
return
|
||||
window_hi = float(np.max(highs[i - period : i]))
|
||||
window_lo = float(np.min(lows[i - period : i]))
|
||||
if (window_hi - window_lo) > max_range:
|
||||
return
|
||||
|
||||
mid = float(opens[i])
|
||||
bar_hi = float(highs[i])
|
||||
bar_lo = float(lows[i])
|
||||
ma_v = float(ma[i - 1])
|
||||
if np.isnan(ma_v):
|
||||
return
|
||||
|
||||
if st.side:
|
||||
if st.side == "BUY":
|
||||
if st.sl > 0 and bar_lo <= st.sl:
|
||||
close(i, st.sl, "sl")
|
||||
elif st.tp > 0 and bar_hi >= st.tp:
|
||||
close(i, st.tp, "tp")
|
||||
else:
|
||||
if st.sl > 0 and bar_hi >= st.sl:
|
||||
close(i, st.sl, "sl")
|
||||
elif st.tp > 0 and bar_lo <= st.tp:
|
||||
close(i, st.tp, "tp")
|
||||
return
|
||||
|
||||
if vols[i] <= vol_thresh:
|
||||
return
|
||||
prev_close = float(closes[i - 1])
|
||||
strength = abs(mid - ma_v) / point
|
||||
break_up = bar_hi > window_hi or prev_close > window_hi
|
||||
break_dn = bar_lo < window_lo or prev_close < window_lo
|
||||
|
||||
if break_up and mid > ma_v and strength > trend_thresh:
|
||||
open_pos(i, "BUY", mid)
|
||||
st.sl = st.entry - sl_pts * point
|
||||
st.tp = st.entry + tp_pts * point
|
||||
elif break_dn and mid < ma_v and strength > trend_thresh:
|
||||
open_pos(i, "SELL", mid)
|
||||
st.sl = st.entry + sl_pts * point
|
||||
st.tp = st.entry - tp_pts * point
|
||||
|
||||
return _run_single_position(
|
||||
df, symbol, point, costs, lot, strategy_id, "M15", period_label, params, 10_000.0, on_bar,
|
||||
)
|
||||
|
||||
|
||||
# --- RSI Secret Sauce (simplified zone exit re-entry) ---
|
||||
def backtest_rsi_secret(df, symbol, period_label, strategy_id, params, lot=0.1, costs=None):
|
||||
info = __import__("MetaTrader5").symbol_info(symbol)
|
||||
point = float(info.point) if info else 0.01
|
||||
costs = costs or CostModel.for_symbol(symbol)
|
||||
rsi = calculate_rsi(df["close"], int(params["rsi_period"])).to_numpy()
|
||||
atr = calculate_atr(df, int(params.get("atr_period", 14))).to_numpy()
|
||||
last_trade_i = -999
|
||||
|
||||
def on_bar(i, st, open_pos, close):
|
||||
nonlocal last_trade_i
|
||||
if i < 30 or np.isnan(rsi[i - 1]) or np.isnan(atr[i - 1]):
|
||||
return
|
||||
mid = float(df["open"].iloc[i])
|
||||
cur, prev = rsi[i - 1], rsi[i - 2]
|
||||
a = atr[i - 1]
|
||||
|
||||
if st.side:
|
||||
if st.side == "BUY":
|
||||
sl = st.entry - params.get("stop_loss_atr", 2) * a
|
||||
tp = st.entry + params.get("take_profit_atr", 4) * a
|
||||
if float(df["low"].iloc[i]) <= sl:
|
||||
close(i, sl, "sl")
|
||||
elif float(df["high"].iloc[i]) >= tp:
|
||||
close(i, tp, "tp")
|
||||
else:
|
||||
sl = st.entry + params.get("stop_loss_atr", 2) * a
|
||||
tp = st.entry - params.get("take_profit_atr", 4) * a
|
||||
if float(df["high"].iloc[i]) >= sl:
|
||||
close(i, sl, "sl")
|
||||
elif float(df["low"].iloc[i]) <= tp:
|
||||
close(i, tp, "tp")
|
||||
return
|
||||
|
||||
if i - last_trade_i < params.get("min_bars_between_trades", 5):
|
||||
return
|
||||
ob, os = params["rsi_overbought"], params["rsi_oversold"]
|
||||
if prev > ob and cur <= ob:
|
||||
open_pos(i, "SELL", mid)
|
||||
last_trade_i = i
|
||||
elif prev < os and cur >= os:
|
||||
open_pos(i, "BUY", mid)
|
||||
last_trade_i = i
|
||||
|
||||
return _run_single_position(
|
||||
df, symbol, point, costs, lot, strategy_id, "M30", period_label, params, 10_000.0, on_bar,
|
||||
)
|
||||
|
||||
|
||||
# --- Simple Trendline (pullback to MA-derived trendline) ---
|
||||
def backtest_simple_trendline(df, symbol, period_label, strategy_id, params, lot=0.1, costs=None):
|
||||
info = __import__("MetaTrader5").symbol_info(symbol)
|
||||
point = float(info.point) if info else 0.01
|
||||
costs = costs or CostModel.for_symbol(symbol)
|
||||
htf = params.get("higher_tf", "H4")
|
||||
htf_map = {"M10": "10min", "M15": "15min", "H1": "1h", "H4": "4h"}
|
||||
rule = htf_map.get(htf, "4h")
|
||||
hdf = df.resample(rule).agg({"open": "first", "high": "max", "low": "min", "close": "last"}).dropna()
|
||||
ma_period = int(params.get("ma_period", 65))
|
||||
ma = calculate_ema(hdf["close"], ma_period).to_numpy()
|
||||
htimes = hdf.index.to_numpy()
|
||||
hcloses = hdf["close"].to_numpy()
|
||||
touch_tol = float(params.get("touch_tolerance_pts", 100)) * point
|
||||
break_buf = float(params.get("break_buffer_pts", 80)) * point
|
||||
|
||||
def line_at(t_model, t_query):
|
||||
x = (t_query - t_model[0]).astype("timedelta64[s]").astype(float)
|
||||
return t_model[2] * x + t_model[3]
|
||||
|
||||
def on_bar(i, st, open_pos, close):
|
||||
if i < 5:
|
||||
return
|
||||
ts = df.index[i]
|
||||
# find 3 most recent HTF MA crosses before ts
|
||||
hidx = int(np.searchsorted(htimes, ts, side="right")) - 1
|
||||
if hidx < ma_period + 5:
|
||||
return
|
||||
crosses_t, crosses_p = [], []
|
||||
for j in range(hidx, ma_period + 2, -1):
|
||||
if j >= len(ma) - 1:
|
||||
continue
|
||||
d0 = hcloses[j] - ma[j]
|
||||
d1 = hcloses[j + 1] - ma[j + 1]
|
||||
if d0 == 0 or d1 == 0 or d0 * d1 < 0:
|
||||
crosses_t.append(htimes[j])
|
||||
crosses_p.append(hcloses[j])
|
||||
if len(crosses_t) >= 3:
|
||||
break
|
||||
if len(crosses_t) < 3:
|
||||
return
|
||||
t0 = crosses_t[2]
|
||||
xs = np.array([(t - t0).astype("timedelta64[s]").astype(float) for t in crosses_t[::-1]])
|
||||
ys = np.array(crosses_p[::-1])
|
||||
den = 3 * np.sum(xs ** 2) - np.sum(xs) ** 2
|
||||
if abs(den) < 1e-10:
|
||||
return
|
||||
a = (3 * np.sum(xs * ys) - np.sum(xs) * np.sum(ys)) / den
|
||||
b = (np.sum(ys) - a * np.sum(xs)) / 3
|
||||
model = (t0, crosses_t, a, b)
|
||||
t1 = df.index[i - 1]
|
||||
line1 = a * (t1 - t0).astype("timedelta64[s]").astype(float) + b
|
||||
mid = float(df["open"].iloc[i])
|
||||
hi = float(df["high"].iloc[i - 1])
|
||||
lo = float(df["low"].iloc[i - 1])
|
||||
cl1 = float(df["close"].iloc[i - 1])
|
||||
op1 = float(df["open"].iloc[i - 1])
|
||||
cl2 = float(df["close"].iloc[i - 2])
|
||||
t2 = df.index[i - 2]
|
||||
line2 = a * (t2 - t0).astype("timedelta64[s]").astype(float) + b
|
||||
|
||||
if st.side == "BUY" and cl1 < line1 - break_buf:
|
||||
close(i, mid, "break")
|
||||
return
|
||||
if st.side == "SELL" and cl1 > line1 + break_buf:
|
||||
close(i, mid, "break")
|
||||
return
|
||||
|
||||
if st.side:
|
||||
return
|
||||
if a > 0:
|
||||
if lo <= line1 + touch_tol and cl1 > line1 and cl1 > op1 and cl2 >= line2 - touch_tol:
|
||||
open_pos(i, "BUY", mid)
|
||||
elif a < 0:
|
||||
if hi >= line1 - touch_tol and cl1 < line1 and cl1 < op1 and cl2 <= line2 + touch_tol:
|
||||
open_pos(i, "SELL", mid)
|
||||
|
||||
return _run_single_position(
|
||||
df, symbol, point, costs, lot, strategy_id, params.get("signal_tf", "H1"),
|
||||
period_label, params, 10_000.0, on_bar,
|
||||
)
|
||||
|
||||
|
||||
# --- USDJPY Asian range breakout (simplified market-fill) ---
|
||||
def backtest_usdjpy_buster(df, symbol, period_label, strategy_id, params, lot=0.1, costs=None):
|
||||
info = __import__("MetaTrader5").symbol_info(symbol)
|
||||
point = float(info.point) if info else 0.001
|
||||
costs = costs or CostModel.for_symbol(symbol)
|
||||
r_start = int(params.get("range_start_hour", 3))
|
||||
r_end = int(params.get("range_end_hour", 6))
|
||||
close_h = int(params.get("close_hour", 18))
|
||||
min_rng = float(params.get("min_range_pts", 15))
|
||||
buf = float(params.get("order_buffer_pts", 4.75)) * point
|
||||
first_only = params.get("first_trade_only", False)
|
||||
|
||||
day_state: dict = {}
|
||||
|
||||
def on_bar(i, st, open_pos, close):
|
||||
ts = df.index[i]
|
||||
dk = ts.date().isoformat()
|
||||
h = ts.hour
|
||||
mid = float(df["open"].iloc[i])
|
||||
|
||||
if st.side and h >= close_h:
|
||||
close(i, mid, "eod")
|
||||
return
|
||||
|
||||
if dk not in day_state:
|
||||
day_state[dk] = {"hi": -np.inf, "lo": np.inf, "built": False, "trades": 0, "range_done": False}
|
||||
|
||||
ds = day_state[dk]
|
||||
if r_start <= h < r_end:
|
||||
ds["hi"] = max(ds["hi"], float(df["high"].iloc[i]))
|
||||
ds["lo"] = min(ds["lo"], float(df["low"].iloc[i]))
|
||||
return
|
||||
|
||||
if not ds["range_done"] and h >= r_end:
|
||||
ds["range_done"] = True
|
||||
if ds["hi"] > ds["lo"] and (ds["hi"] - ds["lo"]) / point >= min_rng:
|
||||
ds["built"] = True
|
||||
|
||||
if not ds["built"] or st.side:
|
||||
return
|
||||
max_tr = 1 if first_only else 2
|
||||
if ds["trades"] >= max_tr:
|
||||
return
|
||||
|
||||
hi = ds["hi"] + buf
|
||||
lo = ds["lo"] - buf
|
||||
bar_hi = float(df["high"].iloc[i])
|
||||
bar_lo = float(df["low"].iloc[i])
|
||||
|
||||
if params.get("allow_long", True) and bar_hi >= hi:
|
||||
open_pos(i, "BUY", mid)
|
||||
st.sl = ds["lo"]
|
||||
ds["trades"] += 1
|
||||
elif params.get("allow_short", True) and bar_lo <= lo:
|
||||
open_pos(i, "SELL", mid)
|
||||
st.sl = ds["hi"]
|
||||
ds["trades"] += 1
|
||||
|
||||
if st.side:
|
||||
if st.side == "BUY" and bar_lo <= st.sl:
|
||||
close(i, st.sl, "sl")
|
||||
elif st.side == "SELL" and bar_hi >= st.sl:
|
||||
close(i, st.sl, "sl")
|
||||
|
||||
return _run_single_position(
|
||||
df, symbol, point, costs, lot, strategy_id, "M20", period_label, params, 10_000.0, on_bar,
|
||||
)
|
||||
|
||||
|
||||
ENGINE_MAP = {
|
||||
"rsi_scalp": backtest_rsi_scalp,
|
||||
"rsi_crossover": backtest_rsi_crossover,
|
||||
"rsi_asian": backtest_rsi_asian,
|
||||
"mean_reversion": backtest_mean_reversion,
|
||||
"ema_slope": backtest_ema_slope,
|
||||
"darvas": backtest_darvas,
|
||||
"rsi_secret": backtest_rsi_secret,
|
||||
"simple_trendline": backtest_simple_trendline,
|
||||
"usdjpy_buster": backtest_usdjpy_buster,
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Analyze losing trades in market context — bars before/after, gaps between losses,
|
||||
RSI/ATR/trend features. Trader-style narrative + param suggestions.
|
||||
|
||||
Usage:
|
||||
python -m cluster_audit.loss_context_analysis united_rsi_scalp_appl
|
||||
python -m cluster_audit.loss_context_analysis united_darvas
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from cluster_audit.backtest_core import CostModel, Trade, load_bars, resolve_symbol
|
||||
from cluster_audit.engines import ENGINE_MAP
|
||||
from cluster_audit.united_registry import PERIODS, UNITED_STRATEGIES
|
||||
from indicator_utils import calculate_atr, calculate_ema, calculate_rsi
|
||||
|
||||
OUT_DIR = Path(__file__).parent / "reports" / "loss_analysis"
|
||||
CONTEXT_BARS = 12 # bars before entry + through hold
|
||||
|
||||
|
||||
def find_spec(sid: str) -> dict:
|
||||
for s in UNITED_STRATEGIES:
|
||||
if s["id"] == sid:
|
||||
return s
|
||||
raise KeyError(sid)
|
||||
|
||||
|
||||
def run_backtest(spec: dict, start: str, end: str) -> tuple[pd.DataFrame, list[Trade], dict]:
|
||||
from cluster_audit.strategy_registry import TF
|
||||
|
||||
sym = resolve_symbol(spec["symbol"])
|
||||
tf_key = spec["tf"]
|
||||
engine = ENGINE_MAP[spec["engine"]]
|
||||
params = dict(spec["defaults"])
|
||||
df = load_bars(sym, TF[tf_key], datetime.fromisoformat(start), datetime.fromisoformat(end))
|
||||
report = engine(df, sym, "2021-2026", spec["id"], params, spec["lot"], CostModel.for_symbol(sym))
|
||||
return df, report.trades_list, params
|
||||
|
||||
|
||||
def bar_features(df: pd.DataFrame, idx: int, rsi: np.ndarray, atr: np.ndarray, ema20: np.ndarray) -> dict:
|
||||
if idx < 1 or idx >= len(df):
|
||||
return {}
|
||||
o, h, l, c = df.iloc[idx][["open", "high", "low", "close"]]
|
||||
prev_c = float(df.iloc[idx - 1]["close"])
|
||||
body = abs(c - o)
|
||||
rng = h - l if h > l else 1e-9
|
||||
return {
|
||||
"rsi": float(rsi[idx - 1]) if not np.isnan(rsi[idx - 1]) else np.nan,
|
||||
"atr": float(atr[idx - 1]) if not np.isnan(atr[idx - 1]) else np.nan,
|
||||
"ema20": float(ema20[idx - 1]) if not np.isnan(ema20[idx - 1]) else np.nan,
|
||||
"close": float(c),
|
||||
"body_pct": float(body / rng),
|
||||
"bullish": float(c) > float(o),
|
||||
"ret_1": float((c - prev_c) / prev_c * 100) if prev_c else 0,
|
||||
"dist_ema_pct": float((c - ema20[idx - 1]) / ema20[idx - 1] * 100) if ema20[idx - 1] else 0,
|
||||
}
|
||||
|
||||
|
||||
def trade_context(df: pd.DataFrame, t: Trade, rsi, atr, ema20) -> dict:
|
||||
open_i = df.index.get_indexer([pd.Timestamp(t.open_time)], method="nearest")[0]
|
||||
close_i = df.index.get_indexer([pd.Timestamp(t.close_time)], method="nearest")[0]
|
||||
pre_start = max(1, open_i - CONTEXT_BARS)
|
||||
pre_bars = []
|
||||
for i in range(pre_start, open_i):
|
||||
pre_bars.append(bar_features(df, i, rsi, atr, ema20))
|
||||
|
||||
hold_bars = []
|
||||
for i in range(open_i, min(close_i + 1, len(df))):
|
||||
hold_bars.append(bar_features(df, i, rsi, atr, ema20))
|
||||
|
||||
entry_f = bar_features(df, open_i, rsi, atr, ema20)
|
||||
exit_f = bar_features(df, close_i, rsi, atr, ema20)
|
||||
|
||||
pre_rsi = [b["rsi"] for b in pre_bars if not np.isnan(b.get("rsi", np.nan))]
|
||||
hold_rsi = [b["rsi"] for b in hold_bars if not np.isnan(b.get("rsi", np.nan))]
|
||||
|
||||
adverse_move = 0.0
|
||||
if t.side == "BUY" and hold_bars:
|
||||
adverse_move = float(t.open_price) - min(b["close"] for b in hold_bars)
|
||||
elif t.side == "SELL" and hold_bars:
|
||||
adverse_move = max(b["close"] for b in hold_bars) - float(t.open_price)
|
||||
|
||||
return {
|
||||
"side": t.side,
|
||||
"exit_reason": t.exit_reason,
|
||||
"profit": t.profit,
|
||||
"bars_held": t.bars_held,
|
||||
"open_time": str(t.open_time),
|
||||
"close_time": str(t.close_time),
|
||||
"entry_rsi": entry_f.get("rsi"),
|
||||
"exit_rsi": exit_f.get("rsi"),
|
||||
"rsi_min_hold": min(hold_rsi) if hold_rsi else None,
|
||||
"rsi_max_hold": max(hold_rsi) if hold_rsi else None,
|
||||
"rsi_trend_pre": (pre_rsi[-1] - pre_rsi[0]) if len(pre_rsi) >= 2 else 0,
|
||||
"adverse_pts": adverse_move,
|
||||
"adverse_atr": adverse_move / entry_f["atr"] if entry_f.get("atr") else 0,
|
||||
"entry_hour": pd.Timestamp(t.open_time).hour,
|
||||
"entry_dist_ema_pct": entry_f.get("dist_ema_pct", 0),
|
||||
"pre_bullish_ratio": sum(1 for b in pre_bars if b.get("bullish")) / max(len(pre_bars), 1),
|
||||
"entry_body_pct": entry_f.get("body_pct", 0),
|
||||
}
|
||||
|
||||
|
||||
def gap_analysis(losers: list[dict]) -> dict:
|
||||
if len(losers) < 2:
|
||||
return {}
|
||||
times = sorted(pd.Timestamp(t["close_time"]) for t in losers)
|
||||
gaps_h = [(times[i] - times[i - 1]).total_seconds() / 3600 for i in range(1, len(times))]
|
||||
return {
|
||||
"median_gap_hours": float(np.median(gaps_h)),
|
||||
"pct_gap_under_4h": float(sum(1 for g in gaps_h if g < 4) / len(gaps_h) * 100),
|
||||
"pct_gap_under_24h": float(sum(1 for g in gaps_h if g < 24) / len(gaps_h) * 100),
|
||||
"clustered": float(sum(1 for g in gaps_h if g < 2) / len(gaps_h) * 100),
|
||||
}
|
||||
|
||||
|
||||
def trader_narrative(sid: str, engine: str, losers_ctx: list[dict], winners_ctx: list[dict], by_reason: dict) -> list[str]:
|
||||
notes: list[str] = []
|
||||
if not losers_ctx:
|
||||
return ["No losing trades to analyze."]
|
||||
|
||||
top_reason = max(by_reason.items(), key=lambda x: x[1]["count"])[0]
|
||||
lr = [c for c in losers_ctx if c["exit_reason"] == top_reason]
|
||||
wr = winners_ctx
|
||||
|
||||
if top_reason == "rsi_against":
|
||||
sell_l = [c for c in lr if c["side"] == "SELL"]
|
||||
buy_l = [c for c in lr if c["side"] == "BUY"]
|
||||
if sell_l:
|
||||
avg_adv = np.mean([c["adverse_atr"] for c in sell_l if c["adverse_atr"]])
|
||||
notes.append(
|
||||
f"SELL rsi_against ({len(sell_l)}): price ripped up avg {avg_adv:.1f} ATR after shorting "
|
||||
f"overbought fade — classic short squeeze / momentum continuation, not mean reversion."
|
||||
)
|
||||
late_h = sum(1 for c in sell_l if c["entry_hour"] >= 18) / len(sell_l) * 100
|
||||
if late_h > 30:
|
||||
notes.append(f"{late_h:.0f}% of losing shorts after 18:00 — avoid fading strength into close.")
|
||||
if buy_l:
|
||||
notes.append(
|
||||
f"BUY rsi_against ({len(buy_l)}): dipped deeper after oversold entry — "
|
||||
f"knife-catching; need deeper OS threshold or wait for RSI curl-up."
|
||||
)
|
||||
if wr:
|
||||
w_sell = [c for c in wr if c["side"] == "SELL"]
|
||||
if w_sell and sell_l:
|
||||
w_rsi = np.mean([c["entry_rsi"] for c in w_sell])
|
||||
l_rsi = np.mean([c["entry_rsi"] for c in sell_l])
|
||||
notes.append(f"Winning shorts entered RSI~{w_rsi:.0f} vs losers~{l_rsi:.0f} — losers entered too early in OB zone.")
|
||||
|
||||
elif top_reason == "sl":
|
||||
notes.append("SL hits: stops inside noise — widen SL to 1.5-2x ATR or reduce lot.")
|
||||
avg_atr = np.mean([c["adverse_atr"] for c in lr if c.get("adverse_atr")])
|
||||
notes.append(f"Avg adverse move before SL = {avg_atr:.1f} ATR — box breakout often retests.")
|
||||
|
||||
elif top_reason == "adverse_atr":
|
||||
notes.append("ATR stop hits: entries fighting trend — fade only when RSI extreme + session filter; widen stop or skip gap-down buys.")
|
||||
buy_l = [c for c in lr if c["side"] == "BUY"]
|
||||
if buy_l:
|
||||
late = sum(1 for c in buy_l if c["entry_hour"] >= 20) / len(buy_l) * 100
|
||||
if late > 25:
|
||||
notes.append(f"{late:.0f}% of stopped-out buys after 20:00 — overnight gap risk on equities.")
|
||||
sell_l = [c for c in lr if c["side"] == "SELL"]
|
||||
if sell_l:
|
||||
avg_adv = np.mean([c["adverse_atr"] for c in sell_l if c.get("adverse_atr")])
|
||||
notes.append(f"Short stops avg {avg_adv:.1f} ATR adverse — momentum continuation, not reversion.")
|
||||
|
||||
elif top_reason == "trail":
|
||||
notes.append("Trail exits: winners cut early in chop — widen trail_distance or raise activation.")
|
||||
|
||||
elif top_reason == "trend_strong":
|
||||
notes.append("trend_strong: exited into momentum — filter only blocks entries, don't force-close in profit.")
|
||||
|
||||
gaps = gap_analysis(losers_ctx)
|
||||
if gaps.get("clustered", 0) > 25:
|
||||
notes.append(
|
||||
f"{gaps['clustered']:.0f}% of losses within 2h of prior loss — regime chop; "
|
||||
f"add cooldown after loss or skip when ATR expanding."
|
||||
)
|
||||
|
||||
return notes
|
||||
|
||||
|
||||
def suggest_params(engine: str, losers_ctx: list[dict], params: dict) -> dict:
|
||||
sug = {}
|
||||
if engine == "rsi_scalp":
|
||||
sell_l = [c for c in losers_ctx if c["side"] == "SELL" and c["exit_reason"] == "rsi_against"]
|
||||
if sell_l and np.mean([c["adverse_atr"] for c in sell_l]) > 1.5:
|
||||
sug["rsi_overbought"] = min(75, params.get("rsi_overbought", 70) + 5)
|
||||
sug["bars_to_wait"] = min(12, params.get("bars_to_wait", 5) + 3)
|
||||
sug["trail_distance_pts"] = params.get("trail_distance_pts", 50) * 1.4
|
||||
sug["skip_short_hour_after"] = 17
|
||||
buy_l = [c for c in losers_ctx if c["side"] == "BUY" and c["exit_reason"] == "rsi_against"]
|
||||
if buy_l:
|
||||
sug["rsi_oversold"] = max(20, params.get("rsi_oversold", 30) - 5)
|
||||
elif engine == "darvas":
|
||||
sug["stop_loss_pts"] = int(params.get("stop_loss_pts", 300) * 1.35)
|
||||
sug["require_retest"] = True
|
||||
return sug
|
||||
|
||||
|
||||
def analyze(sid: str) -> dict:
|
||||
spec = find_spec(sid)
|
||||
start, end = PERIODS["2021-2026"]
|
||||
df, trades, params = run_backtest(spec, start, end)
|
||||
|
||||
rsi = calculate_rsi(df["close"], int(params.get("rsi_period", 14))).to_numpy()
|
||||
atr = calculate_atr(df, 14).to_numpy()
|
||||
ema20 = calculate_ema(df["close"], 20).to_numpy()
|
||||
|
||||
winners = [t for t in trades if t.profit >= 0]
|
||||
losers = [t for t in trades if t.profit < 0]
|
||||
|
||||
losers_ctx = [trade_context(df, t, rsi, atr, ema20) for t in losers]
|
||||
winners_ctx = [trade_context(df, t, rsi, atr, ema20) for t in winners[:200]]
|
||||
|
||||
by_reason: dict = {}
|
||||
for c in losers_ctx:
|
||||
r = c["exit_reason"]
|
||||
bucket = by_reason.setdefault(r, {"count": 0, "pnl": 0.0, "ctx": []})
|
||||
bucket["count"] += 1
|
||||
bucket["pnl"] += c["profit"]
|
||||
bucket["ctx"].append(c)
|
||||
|
||||
narrative = trader_narrative(sid, spec["engine"], losers_ctx, winners_ctx, by_reason)
|
||||
suggestions = suggest_params(spec["engine"], losers_ctx, params)
|
||||
|
||||
result = {
|
||||
"strategy_id": sid,
|
||||
"symbol": spec["symbol"],
|
||||
"engine": spec["engine"],
|
||||
"total_trades": len(trades),
|
||||
"losers": len(losers),
|
||||
"winners": len(winners),
|
||||
"loss_by_reason": {k: {"count": v["count"], "pnl": round(v["pnl"], 2)} for k, v in by_reason.items()},
|
||||
"gap_stats": gap_analysis(losers_ctx),
|
||||
"trader_notes": narrative,
|
||||
"suggested_param_tweaks": suggestions,
|
||||
"sample_losers": sorted(losers_ctx, key=lambda x: x["profit"])[:8],
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sid = sys.argv[1] if len(sys.argv) > 1 else "united_rsi_scalp_appl"
|
||||
if not mt5.initialize():
|
||||
raise SystemExit(f"MT5 init failed: {mt5.last_error()}")
|
||||
try:
|
||||
result = analyze(sid)
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = OUT_DIR / f"{sid}_loss_context.json"
|
||||
path.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
||||
print(f"Wrote {path}\n")
|
||||
print(f"=== {sid} loss context ({result['losers']} losers / {result['total_trades']} trades) ===\n")
|
||||
for reason, stats in sorted(result["loss_by_reason"].items(), key=lambda x: x[1]["pnl"]):
|
||||
print(f" {reason:14} count={stats['count']:4} pnl=${stats['pnl']:,.0f}")
|
||||
print("\nTrader read:")
|
||||
for n in result["trader_notes"]:
|
||||
print(f" - {n}")
|
||||
if result["suggested_param_tweaks"]:
|
||||
print("\nSuggested tweaks:", result["suggested_param_tweaks"])
|
||||
finally:
|
||||
mt5.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Lot sizing guidance for United EA combined portfolio.
|
||||
|
||||
Compares per-strategy risk at 123.set nominal lots and suggests relative weights.
|
||||
|
||||
Usage:
|
||||
python -m cluster_audit.lot_sizing
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from cluster_audit.united_registry import UNITED_STRATEGIES
|
||||
|
||||
REPORTS = Path(__file__).parent / "reports" / "united_sequential"
|
||||
MANIFEST = REPORTS / "united_manifest.json"
|
||||
OUT = REPORTS / "lot_sizing.json"
|
||||
REF_BALANCE = 1000.0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
manifest = {}
|
||||
if MANIFEST.exists():
|
||||
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
|
||||
rows: list[dict] = []
|
||||
for spec in UNITED_STRATEGIES:
|
||||
sid = spec["id"]
|
||||
info = manifest.get("strategies", {}).get(sid, {})
|
||||
if not info.get("passed"):
|
||||
continue
|
||||
o_net = float(info.get("net_profit", 0))
|
||||
trades = int(info.get("trades", 1))
|
||||
dd = float(info.get("max_drawdown_pct", info.get("issues", [""])[0] if False else 5))
|
||||
lot = float(spec["lot"])
|
||||
per_trade = o_net / max(trades, 1)
|
||||
# risk proxy: lot * avg loss magnitude; use net/trades as PnL per trade signal
|
||||
rows.append({
|
||||
"id": sid,
|
||||
"symbol": spec["symbol"],
|
||||
"lot_123set": lot,
|
||||
"net_profit": o_net,
|
||||
"trades": trades,
|
||||
"pnl_per_trade": round(per_trade, 2),
|
||||
"max_dd_pct": dd,
|
||||
})
|
||||
|
||||
if not rows:
|
||||
print("No passed strategies in manifest — run united sequential audit first.")
|
||||
return
|
||||
|
||||
# Target: equal risk contribution via inverse DD weighting
|
||||
inv_dd = [1.0 / max(r.get("max_dd_pct", 5), 0.5) for r in rows]
|
||||
total_inv = sum(inv_dd)
|
||||
for i, r in enumerate(rows):
|
||||
weight = inv_dd[i] / total_inv
|
||||
r["risk_weight"] = round(weight, 4)
|
||||
r["suggested_lot_vs_darvas"] = round(weight / (inv_dd[0] / total_inv), 3) if rows else 1.0
|
||||
|
||||
# Scale all lots so combined net at ref balance ~ sum of individuals / sqrt(N)
|
||||
import math
|
||||
n = len(rows)
|
||||
diversification = math.sqrt(n)
|
||||
base_lot = rows[0]["lot_123set"]
|
||||
for r in rows:
|
||||
r["suggested_lot_at_1k"] = round(base_lot * r["suggested_lot_vs_darvas"] / diversification, 4)
|
||||
|
||||
result = {
|
||||
"reference_balance": REF_BALANCE,
|
||||
"passed_count": n,
|
||||
"diversification_factor": diversification,
|
||||
"note": "suggested_lot_at_1k scales 123.set lots by inverse-DD weight / sqrt(N)",
|
||||
"strategies": rows,
|
||||
}
|
||||
OUT.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
||||
print(f"Wrote {OUT}")
|
||||
print(f"\nLot sizing for {n} passed strategies @ ${REF_BALANCE:,.0f} reference:\n")
|
||||
for r in rows:
|
||||
print(
|
||||
f" {r['id']:28} lot={r['lot_123set']:8} -> suggested={r['suggested_lot_at_1k']:8} "
|
||||
f"(weight={r['risk_weight']:.2%}, pnl/trade=${r['pnl_per_trade']:.2f})"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Margin helpers — uses MT5 order_calc_margin for realistic portfolio sizing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
|
||||
|
||||
def calc_margin(symbol: str, side: str, volume: float, price: float) -> float:
|
||||
ot = mt5.ORDER_TYPE_BUY if side == "BUY" else mt5.ORDER_TYPE_SELL
|
||||
m = mt5.order_calc_margin(ot, symbol, volume, price)
|
||||
return float(m) if m is not None and m > 0 else 0.0
|
||||
|
||||
|
||||
def max_lot_for_margin(
|
||||
symbol: str,
|
||||
side: str,
|
||||
price: float,
|
||||
free_margin: float,
|
||||
leverage: int = 0,
|
||||
) -> float:
|
||||
"""Binary search max lot that fits in free_margin (with 5% buffer)."""
|
||||
info = mt5.symbol_info(symbol)
|
||||
if info is None or free_margin <= 0:
|
||||
return 0.0
|
||||
vmin = float(info.volume_min)
|
||||
vmax = float(info.volume_max)
|
||||
step = float(info.volume_step) or vmin
|
||||
budget = free_margin * 0.95
|
||||
lo, hi = vmin, vmax
|
||||
best = 0.0
|
||||
for _ in range(24):
|
||||
mid = (lo + hi) / 2
|
||||
m = calc_margin(symbol, side, mid, price)
|
||||
if m <= budget:
|
||||
best = mid
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
if step > 0 and best > 0:
|
||||
best = max(vmin, (int(best / step)) * step)
|
||||
return best
|
||||
|
||||
|
||||
def normalize_volume(symbol: str, volume: float) -> float:
|
||||
info = mt5.symbol_info(symbol)
|
||||
if info is None:
|
||||
return volume
|
||||
vmin = float(info.volume_min)
|
||||
vmax = float(info.volume_max)
|
||||
step = float(info.volume_step) or vmin
|
||||
if step > 0:
|
||||
volume = (int(volume / step)) * step
|
||||
return max(vmin, min(vmax, volume))
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Progressive portfolio build: combine 1, 2, 3 ... N strategies with lot optimization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .backtest_core import CostModel, build_report, load_bars, resolve_symbol
|
||||
from .engines import ENGINE_MAP
|
||||
from .strategy_registry import TF
|
||||
from .trace_log import TraceLog
|
||||
|
||||
|
||||
def _score_report(report) -> float:
|
||||
if report.total_trades < 5:
|
||||
return float("-inf")
|
||||
return report.sharpe * 0.6 + (report.net_profit / 1000.0) * 0.3 - report.max_drawdown_pct * 0.1
|
||||
|
||||
|
||||
def _align_equity_curves(curves: list[pd.Series], initial: float = 10_000.0) -> pd.Series:
|
||||
if not curves:
|
||||
return pd.Series([initial])
|
||||
idx = curves[0].index
|
||||
for c in curves[1:]:
|
||||
idx = idx.union(c.index)
|
||||
idx = idx.sort_values()
|
||||
combined = pd.Series(0.0, index=idx)
|
||||
for c in curves:
|
||||
delta = c - c.iloc[0]
|
||||
combined = combined.add(delta.reindex(idx, method="ffill").fillna(0.0), fill_value=0.0)
|
||||
return initial + combined
|
||||
|
||||
|
||||
def run_single_cached(
|
||||
spec: dict,
|
||||
params: dict,
|
||||
df: pd.DataFrame,
|
||||
sym: str,
|
||||
period_label: str,
|
||||
lot_mult: float = 1.0,
|
||||
) -> tuple[Any, pd.Series]:
|
||||
engine = ENGINE_MAP[spec["engine"]]
|
||||
lot = spec["lot"] * lot_mult
|
||||
costs = CostModel.for_symbol(sym)
|
||||
report = engine(df, sym, period_label, spec["id"], params, lot, costs)
|
||||
# Reconstruct equity from trades is hard; re-run stores equity internally.
|
||||
# Use monthly returns proxy: build flat equity from trade PnL timeline.
|
||||
eq = pd.Series(10_000.0, index=df.index)
|
||||
pnl = 0.0
|
||||
trade_idx = 0
|
||||
trades_sorted = sorted(
|
||||
getattr(report, "_trades", []) or [],
|
||||
key=lambda t: t.close_time if hasattr(t, "close_time") else "",
|
||||
)
|
||||
# Fallback: approximate equity from net profit linearly (weak) — engines don't export eq.
|
||||
# Better: patch engines to return equity. For now use per-strategy report sharpe weighting only.
|
||||
if report.total_trades > 0:
|
||||
step = report.net_profit / max(len(df), 1)
|
||||
eq = eq + np.arange(len(df)) * (step / len(df))
|
||||
return report, eq
|
||||
|
||||
|
||||
def backtest_portfolio(
|
||||
members: list[dict],
|
||||
period_label: str,
|
||||
start: str,
|
||||
end: str,
|
||||
lot_mults: dict[str, float] | None = None,
|
||||
data_cache: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""members: list of {spec, params, lot_mult}"""
|
||||
lot_mults = lot_mults or {}
|
||||
data_cache = data_cache or {}
|
||||
curves: list[pd.Series] = []
|
||||
member_reports = []
|
||||
all_trades = []
|
||||
|
||||
start_dt = datetime.fromisoformat(start)
|
||||
end_dt = datetime.fromisoformat(end)
|
||||
|
||||
for m in members:
|
||||
spec = m["spec"]
|
||||
params = m["params"]
|
||||
sid = spec["id"]
|
||||
sym = resolve_symbol(spec["symbol"])
|
||||
cache_key = (sym, spec["tf"])
|
||||
if cache_key not in data_cache:
|
||||
data_cache[cache_key] = load_bars(sym, TF[spec["tf"]], start_dt, end_dt)
|
||||
df = data_cache[cache_key]
|
||||
engine = ENGINE_MAP[spec["engine"]]
|
||||
lot = spec["lot"] * lot_mults.get(sid, m.get("lot_mult", 1.0))
|
||||
costs = CostModel.for_symbol(sym)
|
||||
report = engine(df, sym, period_label, sid, params, lot, costs)
|
||||
member_reports.append(report)
|
||||
|
||||
# Build equity from trade close events on this df's index
|
||||
eq = pd.Series(10_000.0, index=df.index, dtype=float)
|
||||
running = 10_000.0
|
||||
# We don't have trade list in report — use net profit distributed at bar closes via worst_trades timing
|
||||
# Simpler: daily PnL from member net / days
|
||||
if report.total_trades > 0 and report.net_profit != 0:
|
||||
daily_ret = report.net_profit / len(df)
|
||||
eq = eq + pd.Series(np.cumsum([daily_ret] * len(df)), index=df.index)
|
||||
curves.append(eq)
|
||||
all_trades.append(report.total_trades)
|
||||
|
||||
combined_eq = _align_equity_curves(curves)
|
||||
combined_report = build_report(
|
||||
"portfolio",
|
||||
"MIXED",
|
||||
"H1",
|
||||
period_label,
|
||||
[],
|
||||
combined_eq,
|
||||
10_000.0,
|
||||
{"members": [m["spec"]["id"] for m in members]},
|
||||
)
|
||||
# Override with summed stats
|
||||
net = sum(r.net_profit for r in member_reports)
|
||||
trades = sum(r.total_trades for r in member_reports)
|
||||
sharpes = [r.sharpe for r in member_reports if r.total_trades >= 5]
|
||||
combined_report.net_profit = net
|
||||
combined_report.total_trades = trades
|
||||
combined_report.sharpe = float(np.mean(sharpes)) if sharpes else 0.0
|
||||
|
||||
return {
|
||||
"members": [m["spec"]["id"] for m in members],
|
||||
"member_reports": [r.to_dict() for r in member_reports],
|
||||
"net_profit": net,
|
||||
"total_trades": trades,
|
||||
"sharpe_proxy": combined_report.sharpe,
|
||||
"lot_mults": {m["spec"]["id"]: lot_mults.get(m["spec"]["id"], m.get("lot_mult", 1.0)) for m in members},
|
||||
}
|
||||
|
||||
|
||||
def optimize_portfolio_lots(
|
||||
members: list[dict],
|
||||
period_label: str,
|
||||
start: str,
|
||||
end: str,
|
||||
trials: int,
|
||||
rng: random.Random,
|
||||
log: TraceLog,
|
||||
) -> dict[str, Any]:
|
||||
best_mults = {m["spec"]["id"]: 1.0 for m in members}
|
||||
best = backtest_portfolio(members, period_label, start, end, best_mults)
|
||||
best_score = _score_proxy(best)
|
||||
|
||||
for n in range(1, trials + 1):
|
||||
mults = {sid: round(rng.uniform(0.25, 1.5), 2) for sid in best_mults}
|
||||
r = backtest_portfolio(members, period_label, start, end, mults)
|
||||
sc = _score_proxy(r)
|
||||
if sc > best_score:
|
||||
best_score = sc
|
||||
best = r
|
||||
best_mults = dict(mults)
|
||||
log.info(f" portfolio trial {n}/{trials}: NEW BEST net=${r['net_profit']:.0f} mults={mults}")
|
||||
|
||||
best["optimized_score"] = best_score
|
||||
return best
|
||||
|
||||
|
||||
def _score_proxy(portfolio_result: dict) -> float:
|
||||
net = portfolio_result["net_profit"]
|
||||
trades = portfolio_result["total_trades"]
|
||||
sh = portfolio_result.get("sharpe_proxy", 0.0)
|
||||
if trades < 5:
|
||||
return float("-inf")
|
||||
return sh * 0.6 + (net / 1000.0) * 0.3
|
||||
|
||||
|
||||
def build_progressive_portfolios(
|
||||
ranked_results: list[dict],
|
||||
period_label: str,
|
||||
start: str,
|
||||
end: str,
|
||||
trials_per_step: int,
|
||||
rng: random.Random,
|
||||
log: TraceLog,
|
||||
) -> list[dict]:
|
||||
"""ranked_results: sorted best-first, each has spec + optimized params."""
|
||||
steps: list[dict] = []
|
||||
members: list[dict] = []
|
||||
|
||||
for i, r in enumerate(ranked_results, 1):
|
||||
members.append({"spec": r["spec"], "params": r["optimized_params"], "lot_mult": 1.0})
|
||||
log.banner(f"PORTFOLIO STEP {i}/{len(ranked_results)}: +{r['spec']['id']}")
|
||||
log.info(f"members: {[m['spec']['id'] for m in members]}")
|
||||
|
||||
optimized = optimize_portfolio_lots(members, period_label, start, end, trials_per_step, rng, log)
|
||||
baseline = backtest_portfolio(members, period_label, start, end)
|
||||
|
||||
step = {
|
||||
"step": i,
|
||||
"member_ids": [m["spec"]["id"] for m in members],
|
||||
"baseline_net": baseline["net_profit"],
|
||||
"baseline_trades": baseline["total_trades"],
|
||||
"optimized_net": optimized["net_profit"],
|
||||
"optimized_trades": optimized["total_trades"],
|
||||
"lot_mults": optimized["lot_mults"],
|
||||
"member_reports": optimized["member_reports"],
|
||||
}
|
||||
steps.append(step)
|
||||
log.info(
|
||||
f"step {i}: baseline net=${baseline['net_profit']:.0f} -> "
|
||||
f"optimized net=${optimized['net_profit']:.0f} mults={optimized['lot_mults']}"
|
||||
)
|
||||
|
||||
return steps
|
||||
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Margin-aware portfolio simulator — merges strategy trades chronologically.
|
||||
|
||||
Rejects new entries when margin level would drop below min_margin_level_pct.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .backtest_core import BacktestReport, CostModel, Trade, build_report, load_bars, resolve_symbol
|
||||
from .engines import ENGINE_MAP
|
||||
from .margin import calc_margin, normalize_volume
|
||||
from .united_registry import TF
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenPosition:
|
||||
strategy_id: str
|
||||
symbol: str
|
||||
side: str
|
||||
volume: float
|
||||
entry_price: float
|
||||
entry_time: Any
|
||||
margin: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortfolioSimResult:
|
||||
members: list[str]
|
||||
lot_scales: dict[str, float]
|
||||
initial_balance: float
|
||||
net_profit: float
|
||||
total_trades: int
|
||||
rejected_margin: int
|
||||
min_margin_level_pct: float
|
||||
lowest_margin_level_pct: float
|
||||
max_drawdown_pct: float
|
||||
sharpe: float
|
||||
equity_curve: pd.Series = field(repr=False)
|
||||
member_reports: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def _scale_trade(t: Trade, scale: float) -> Trade:
|
||||
if scale == 1.0:
|
||||
return t
|
||||
return Trade(
|
||||
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 * scale,
|
||||
profit=t.profit * scale,
|
||||
bars_held=t.bars_held,
|
||||
exit_reason=t.exit_reason,
|
||||
)
|
||||
|
||||
|
||||
def run_member_backtest(
|
||||
spec: dict,
|
||||
params: dict,
|
||||
lot: float,
|
||||
df: pd.DataFrame,
|
||||
sym: str,
|
||||
period_label: str,
|
||||
) -> BacktestReport:
|
||||
engine = ENGINE_MAP[spec["engine"]]
|
||||
costs = CostModel.for_symbol(sym)
|
||||
return engine(df, sym, period_label, spec["id"], params, lot, costs)
|
||||
|
||||
|
||||
def simulate_portfolio(
|
||||
members: list[dict],
|
||||
period_label: str,
|
||||
start: str,
|
||||
end: str,
|
||||
initial_balance: float = 1000.0,
|
||||
lot_scales: dict[str, float] | None = None,
|
||||
min_margin_level_pct: float = 150.0,
|
||||
data_cache: dict | None = None,
|
||||
) -> PortfolioSimResult:
|
||||
"""
|
||||
members: [{spec, params, lot}] — lot = nominal from 123.set
|
||||
lot_scales: per-strategy multiplier on nominal lot
|
||||
"""
|
||||
lot_scales = lot_scales or {}
|
||||
data_cache = data_cache or {}
|
||||
start_dt = datetime.fromisoformat(start)
|
||||
end_dt = datetime.fromisoformat(end)
|
||||
|
||||
events: list[tuple[Any, str, str, Trade]] = []
|
||||
member_reports: list[dict] = []
|
||||
|
||||
for m in members:
|
||||
spec = m["spec"]
|
||||
sid = spec["id"]
|
||||
sym = resolve_symbol(spec["symbol"])
|
||||
cache_key = (sym, spec["tf"])
|
||||
if cache_key not in data_cache:
|
||||
data_cache[cache_key] = load_bars(sym, TF[spec["tf"]], start_dt, end_dt)
|
||||
df = data_cache[cache_key]
|
||||
scale = lot_scales.get(sid, m.get("lot_scale", 1.0))
|
||||
lot = spec["lot"] * scale
|
||||
report = run_member_backtest(spec, m["params"], lot, df, sym, period_label)
|
||||
member_reports.append({**report.to_dict(), "lot_used": lot, "lot_scale": scale})
|
||||
for t in report.trades_list:
|
||||
events.append((t.open_time, "open", sid, t))
|
||||
events.append((t.close_time, "close", sid, t))
|
||||
|
||||
events.sort(key=lambda x: (pd.Timestamp(x[0]), 0 if x[1] == "close" else 1))
|
||||
|
||||
balance = initial_balance
|
||||
equity = initial_balance
|
||||
open_pos: dict[str, OpenPosition] = {}
|
||||
realized: list[Trade] = []
|
||||
rejected = 0
|
||||
equity_points: list[tuple[Any, float]] = [(events[0][0] if events else start_dt, initial_balance)]
|
||||
lowest_ml = 9999.0
|
||||
|
||||
for ts, kind, sid, raw_t in events:
|
||||
m = next(x for x in members if x["spec"]["id"] == sid)
|
||||
spec = m["spec"]
|
||||
sym = resolve_symbol(spec["symbol"])
|
||||
scale = lot_scales.get(sid, m.get("lot_scale", 1.0))
|
||||
t = _scale_trade(raw_t, scale / (raw_t.volume / spec["lot"]) if raw_t.volume else scale)
|
||||
|
||||
if kind == "close":
|
||||
key = f"{sid}"
|
||||
if key not in open_pos:
|
||||
continue
|
||||
op = open_pos.pop(key)
|
||||
balance += t.profit
|
||||
equity = balance + sum(
|
||||
calc_profit(op2.symbol, op2.side, op2.volume, op2.entry_price, t.close_price)
|
||||
for op2 in open_pos.values()
|
||||
if op2.symbol == sym
|
||||
)
|
||||
# simpler: balance only on close
|
||||
balance = equity_points[-1][1] + t.profit if equity_points else balance + t.profit
|
||||
realized.append(t)
|
||||
equity_points.append((ts, balance))
|
||||
continue
|
||||
|
||||
# open
|
||||
vol = normalize_volume(sym, spec["lot"] * lot_scales.get(sid, 1.0))
|
||||
if vol <= 0:
|
||||
rejected += 1
|
||||
continue
|
||||
margin_req = calc_margin(sym, t.side, vol, t.open_price)
|
||||
used_margin = sum(p.margin for p in open_pos.values())
|
||||
free = balance - used_margin
|
||||
if margin_req > free:
|
||||
rejected += 1
|
||||
continue
|
||||
new_used = used_margin + margin_req
|
||||
equity = balance # simplified
|
||||
ml = (equity / new_used * 100.0) if new_used > 0 else 9999.0
|
||||
if ml < min_margin_level_pct:
|
||||
rejected += 1
|
||||
continue
|
||||
lowest_ml = min(lowest_ml, ml)
|
||||
open_pos[sid] = OpenPosition(sid, sym, t.side, vol, t.open_price, ts, margin_req)
|
||||
|
||||
if not equity_points:
|
||||
equity_points = [(start_dt, initial_balance)]
|
||||
|
||||
eq = pd.Series(
|
||||
[p[1] for p in equity_points],
|
||||
index=pd.DatetimeIndex([p[0] for p in equity_points]),
|
||||
)
|
||||
combined = build_report(
|
||||
"portfolio",
|
||||
"MIXED",
|
||||
"H1",
|
||||
period_label,
|
||||
realized,
|
||||
eq,
|
||||
initial_balance,
|
||||
{"members": [m["spec"]["id"] for m in members], "lot_scales": lot_scales},
|
||||
)
|
||||
|
||||
return PortfolioSimResult(
|
||||
members=[m["spec"]["id"] for m in members],
|
||||
lot_scales={m["spec"]["id"]: lot_scales.get(m["spec"]["id"], 1.0) for m in members},
|
||||
initial_balance=initial_balance,
|
||||
net_profit=combined.net_profit,
|
||||
total_trades=len(realized),
|
||||
rejected_margin=rejected,
|
||||
min_margin_level_pct=min_margin_level_pct,
|
||||
lowest_margin_level_pct=lowest_ml if lowest_ml < 9999 else 0.0,
|
||||
max_drawdown_pct=combined.max_drawdown_pct,
|
||||
sharpe=combined.sharpe,
|
||||
equity_curve=eq,
|
||||
member_reports=member_reports,
|
||||
)
|
||||
|
||||
|
||||
def optimize_lot_scales(
|
||||
members: list[dict],
|
||||
period_label: str,
|
||||
start: str,
|
||||
end: str,
|
||||
initial_balance: float,
|
||||
min_margin_level_pct: float,
|
||||
trials: int,
|
||||
rng,
|
||||
) -> PortfolioSimResult:
|
||||
"""Grid-search lot scales down from 1.0 — margin-safe, maximize net."""
|
||||
best_scales = {m["spec"]["id"]: 1.0 for m in members}
|
||||
best = simulate_portfolio(
|
||||
members, period_label, start, end, initial_balance, best_scales, min_margin_level_pct
|
||||
)
|
||||
best_score = _portfolio_score(best)
|
||||
|
||||
# Coarse: try uniform scale factors
|
||||
for factor in [1.0, 0.75, 0.5, 0.35, 0.25, 0.15, 0.1]:
|
||||
scales = {m["spec"]["id"]: factor for m in members}
|
||||
r = simulate_portfolio(members, period_label, start, end, initial_balance, scales, min_margin_level_pct)
|
||||
sc = _portfolio_score(r)
|
||||
if sc > best_score:
|
||||
best_score = sc
|
||||
best = r
|
||||
best_scales = dict(scales)
|
||||
|
||||
# Fine-tune per member around best uniform
|
||||
for _ in range(trials):
|
||||
scales = {}
|
||||
for m in members:
|
||||
sid = m["spec"]["id"]
|
||||
base = best_scales.get(sid, 1.0)
|
||||
scales[sid] = round(max(0.05, min(1.5, base * rng.uniform(0.7, 1.3))), 3)
|
||||
r = simulate_portfolio(members, period_label, start, end, initial_balance, scales, min_margin_level_pct)
|
||||
sc = _portfolio_score(r)
|
||||
if sc > best_score and r.lowest_margin_level_pct >= min_margin_level_pct * 0.9:
|
||||
best_score = sc
|
||||
best = r
|
||||
best_scales = dict(scales)
|
||||
|
||||
best.lot_scales = best_scales
|
||||
return best
|
||||
|
||||
|
||||
def _portfolio_score(r: PortfolioSimResult) -> float:
|
||||
if r.net_profit <= 0:
|
||||
return float("-inf")
|
||||
if r.lowest_margin_level_pct < r.min_margin_level_pct:
|
||||
return float("-inf")
|
||||
return r.sharpe * 0.4 + (r.net_profit / 500.0) * 0.4 - r.max_drawdown_pct * 0.15 - r.rejected_margin * 0.001
|
||||
|
||||
|
||||
def build_progressive_margin_portfolio(
|
||||
ranked: list[dict],
|
||||
period_label: str,
|
||||
start: str,
|
||||
end: str,
|
||||
initial_balance: float,
|
||||
min_margin_level_pct: float,
|
||||
trials_per_step: int,
|
||||
rng,
|
||||
) -> list[dict]:
|
||||
"""ranked: [{spec, params, lot, baseline_report}] sorted best-first."""
|
||||
steps: list[dict] = []
|
||||
members: list[dict] = []
|
||||
|
||||
for i, r in enumerate(ranked, 1):
|
||||
members.append({
|
||||
"spec": r["spec"],
|
||||
"params": r["params"],
|
||||
"lot_scale": 1.0,
|
||||
})
|
||||
baseline = simulate_portfolio(
|
||||
members, period_label, start, end, initial_balance,
|
||||
{m["spec"]["id"]: 1.0 for m in members}, min_margin_level_pct,
|
||||
)
|
||||
optimized = optimize_lot_scales(
|
||||
members, period_label, start, end, initial_balance,
|
||||
min_margin_level_pct, trials_per_step, rng,
|
||||
)
|
||||
steps.append({
|
||||
"step": i,
|
||||
"members": [m["spec"]["id"] for m in members],
|
||||
"baseline_net": baseline.net_profit,
|
||||
"baseline_trades": baseline.total_trades,
|
||||
"baseline_lowest_margin_pct": baseline.lowest_margin_level_pct,
|
||||
"optimized_net": optimized.net_profit,
|
||||
"optimized_trades": optimized.total_trades,
|
||||
"optimized_sharpe": optimized.sharpe,
|
||||
"optimized_max_dd_pct": optimized.max_drawdown_pct,
|
||||
"lowest_margin_level_pct": optimized.lowest_margin_level_pct,
|
||||
"rejected_margin": optimized.rejected_margin,
|
||||
"lot_scales": optimized.lot_scales,
|
||||
"lots_final": {
|
||||
m["spec"]["id"]: round(m["spec"]["lot"] * optimized.lot_scales.get(m["spec"]["id"], 1.0), 4)
|
||||
for m in members
|
||||
},
|
||||
})
|
||||
return steps
|
||||
@@ -0,0 +1 @@
|
||||
# Local MT5 audit outputs — see cluster_audit/*.py to regenerate.
|
||||
@@ -0,0 +1,646 @@
|
||||
"""
|
||||
|
||||
Full cluster audit: baseline + optimize per strategy per period.
|
||||
|
||||
Outputs JSON report with worst losses and improvement hints.
|
||||
|
||||
|
||||
|
||||
Usage:
|
||||
|
||||
python -m cluster_audit.run_audit [trials] [--trial-every N] [--quiet]
|
||||
|
||||
|
||||
|
||||
Examples:
|
||||
|
||||
python -m cluster_audit.run_audit 80
|
||||
|
||||
python -m cluster_audit.run_audit 120 --trial-every 5
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
|
||||
import argparse
|
||||
|
||||
import json
|
||||
|
||||
import random
|
||||
|
||||
import sys
|
||||
|
||||
import time
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
|
||||
|
||||
from cluster_audit.backtest_core import CostModel, load_bars, resolve_symbol
|
||||
|
||||
from cluster_audit.engines import ENGINE_MAP
|
||||
|
||||
from cluster_audit.strategy_registry import PERIODS, STRATEGIES, TF
|
||||
|
||||
from cluster_audit.trace_log import TraceLog
|
||||
|
||||
|
||||
|
||||
LOG = TraceLog(enabled=True, trial_every=10)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _sample_params(defaults: dict, opt_ranges: dict, rng: random.Random) -> dict:
|
||||
|
||||
p = dict(defaults)
|
||||
|
||||
for key, spec in opt_ranges.items():
|
||||
|
||||
if not isinstance(spec, tuple) or len(spec) != 3:
|
||||
|
||||
continue
|
||||
|
||||
lo, hi, step = spec
|
||||
|
||||
if isinstance(lo, int):
|
||||
|
||||
vals = list(range(int(lo), int(hi) + 1, int(step)))
|
||||
|
||||
p[key] = rng.choice(vals) if vals else p.get(key, lo)
|
||||
|
||||
else:
|
||||
|
||||
n = int((hi - lo) / step) + 1
|
||||
|
||||
idx = rng.randint(0, max(n - 1, 0))
|
||||
|
||||
p[key] = round(lo + idx * step, 4)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from cluster_audit.scoring import DEFAULT_TRADES_PER_DAY, period_days, score_label, score_report
|
||||
from cluster_audit.strategy_registry import PERIODS
|
||||
|
||||
|
||||
def _audit_period_days() -> int:
|
||||
start, end = PERIODS["2021-2026"]
|
||||
return period_days(start, end)
|
||||
|
||||
|
||||
def _score_label(score: float, trades: int) -> str:
|
||||
return score_label(score, trades, _audit_period_days(), DEFAULT_TRADES_PER_DAY)
|
||||
|
||||
|
||||
def _score(report) -> float:
|
||||
return score_report(report, _audit_period_days(), DEFAULT_TRADES_PER_DAY)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def run_strategy(
|
||||
|
||||
spec: dict,
|
||||
|
||||
period_label: str,
|
||||
|
||||
start: str,
|
||||
|
||||
end: str,
|
||||
|
||||
trials: int,
|
||||
|
||||
rng: random.Random,
|
||||
|
||||
run_idx: int,
|
||||
|
||||
run_total: int,
|
||||
|
||||
) -> dict:
|
||||
|
||||
sid = spec["id"]
|
||||
|
||||
label = f"{sid} @ {period_label} ({run_idx}/{run_total})"
|
||||
|
||||
LOG.phase_start(label)
|
||||
|
||||
|
||||
|
||||
engine_name = spec["engine"]
|
||||
|
||||
if engine_name not in ENGINE_MAP:
|
||||
|
||||
LOG.error(f"Unknown engine '{engine_name}'")
|
||||
|
||||
return {"id": sid, "period": period_label, "error": f"unknown engine {engine_name}"}
|
||||
|
||||
|
||||
|
||||
engine = ENGINE_MAP[engine_name]
|
||||
|
||||
tf_key = spec["tf"]
|
||||
|
||||
tf = TF[tf_key]
|
||||
|
||||
|
||||
|
||||
LOG.debug(f"resolve symbol: requested={spec['symbol']}")
|
||||
|
||||
raw_sym = spec["symbol"]
|
||||
|
||||
sym = resolve_symbol(raw_sym)
|
||||
|
||||
if sym != raw_sym:
|
||||
|
||||
LOG.info(f"symbol mapped {raw_sym} -> {sym}")
|
||||
|
||||
else:
|
||||
|
||||
LOG.debug(f"symbol={sym}")
|
||||
|
||||
|
||||
|
||||
start_dt = datetime.fromisoformat(start)
|
||||
|
||||
end_dt = datetime.fromisoformat(end)
|
||||
|
||||
LOG.debug(f"load bars: tf={tf_key} from={start} to={end} lot={spec['lot']}")
|
||||
|
||||
|
||||
|
||||
t_load = time.perf_counter()
|
||||
|
||||
try:
|
||||
|
||||
df = load_bars(sym, tf, start_dt, end_dt)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
LOG.error(f"data load failed: {e}")
|
||||
|
||||
LOG.phase_end(label, "SKIPPED")
|
||||
|
||||
return {"id": sid, "period": period_label, "error": str(e)}
|
||||
|
||||
|
||||
|
||||
load_ms = (time.perf_counter() - t_load) * 1000
|
||||
|
||||
LOG.info(
|
||||
|
||||
f"loaded {len(df)} bars in {load_ms:.0f}ms "
|
||||
|
||||
f"({df.index[0]} -> {df.index[-1]})"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
costs = CostModel.for_symbol(sym)
|
||||
|
||||
LOG.debug(
|
||||
|
||||
f"costs: spread={costs.spread_points}pts slippage={costs.slippage_points}pts "
|
||||
|
||||
f"commission/lot={costs.commission_per_lot}"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
defaults = dict(spec["defaults"])
|
||||
|
||||
opt_keys = list(spec.get("opt", {}).keys())
|
||||
|
||||
LOG.debug(f"baseline params keys: {list(defaults.keys())}")
|
||||
|
||||
LOG.debug(f"optimize keys ({len(opt_keys)}): {opt_keys}")
|
||||
|
||||
|
||||
|
||||
LOG.debug("running baseline backtest...")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
baseline = engine(df, sym, period_label, sid, defaults, spec["lot"], costs)
|
||||
|
||||
base_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
LOG.info(
|
||||
|
||||
f"baseline done in {base_ms:.0f}ms: net=${baseline.net_profit:.2f} "
|
||||
|
||||
f"sharpe={baseline.sharpe:.2f} trades={baseline.total_trades} "
|
||||
|
||||
f"pf={baseline.profit_factor:.2f} max_dd={baseline.max_drawdown_pct:.1f}%"
|
||||
|
||||
)
|
||||
|
||||
if baseline.total_trades == 0:
|
||||
|
||||
LOG.warn(f"{sid}: 0 trades on baseline — engine may not match MQL or params too strict")
|
||||
|
||||
|
||||
|
||||
best = baseline
|
||||
|
||||
best_params = defaults
|
||||
|
||||
best_score = _score(baseline)
|
||||
|
||||
LOG.debug(f"baseline score={_score_label(best_score, baseline.total_trades)}")
|
||||
|
||||
|
||||
|
||||
if trials > 0:
|
||||
|
||||
LOG.info(f"optimizing: {trials} random trials...")
|
||||
|
||||
for n in range(1, trials + 1):
|
||||
|
||||
params = _sample_params(defaults, spec.get("opt", {}), rng)
|
||||
|
||||
r = engine(df, sym, period_label, sid, params, spec["lot"], costs)
|
||||
|
||||
sc = _score(r)
|
||||
|
||||
improved = sc > best_score
|
||||
|
||||
if improved:
|
||||
|
||||
best_score = sc
|
||||
|
||||
best = r
|
||||
|
||||
best_params = params
|
||||
|
||||
LOG.trial(n, trials, sc, r.net_profit, r.sharpe, improved=True)
|
||||
|
||||
LOG.debug(f" new best params sample: { {k: best_params[k] for k in opt_keys[:6] if k in best_params} }")
|
||||
|
||||
else:
|
||||
|
||||
LOG.trial(n, trials, sc, r.net_profit, r.sharpe, improved=False)
|
||||
|
||||
|
||||
|
||||
if trials > 0 and best is not baseline:
|
||||
|
||||
LOG.info(
|
||||
|
||||
f"optimized: net +${best.net_profit - baseline.net_profit:.0f} "
|
||||
|
||||
f"sharpe +{best.sharpe - baseline.sharpe:.2f}"
|
||||
|
||||
)
|
||||
|
||||
elif trials > 0:
|
||||
|
||||
LOG.info("optimization: no better params found (baseline kept)")
|
||||
|
||||
|
||||
|
||||
loss_reasons: dict[str, float] = {}
|
||||
|
||||
for t in baseline.worst_trades:
|
||||
|
||||
reason = t.get("exit_reason", "?")
|
||||
|
||||
loss_reasons[reason] = loss_reasons.get(reason, 0) + min(t["profit"], 0)
|
||||
|
||||
if loss_reasons:
|
||||
|
||||
top_loss = sorted(loss_reasons.items(), key=lambda x: x[1])[:3]
|
||||
|
||||
LOG.debug(f"baseline loss by exit reason: {top_loss}")
|
||||
|
||||
|
||||
|
||||
result = {
|
||||
|
||||
"id": sid,
|
||||
|
||||
"engine": engine_name,
|
||||
|
||||
"symbol": sym,
|
||||
|
||||
"timeframe": tf_key,
|
||||
|
||||
"period": period_label,
|
||||
|
||||
"bars": len(df),
|
||||
|
||||
"baseline": baseline.to_dict(),
|
||||
|
||||
"optimized": {**best.to_dict(), "params": best_params},
|
||||
|
||||
"improvement_net": best.net_profit - baseline.net_profit,
|
||||
|
||||
"improvement_sharpe": best.sharpe - baseline.sharpe,
|
||||
|
||||
"loss_reasons_baseline": loss_reasons,
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
LOG.report_line(sid, result["baseline"], result["optimized"], len(df))
|
||||
|
||||
LOG.phase_end(label)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def build_improvement_plan(results: list[dict]) -> dict:
|
||||
|
||||
by_engine: dict[str, list] = {}
|
||||
|
||||
for r in results:
|
||||
|
||||
if "error" in r:
|
||||
|
||||
continue
|
||||
|
||||
by_engine.setdefault(r["engine"], []).append(r)
|
||||
|
||||
|
||||
|
||||
plan = {"per_engine": {}, "portfolio": []}
|
||||
|
||||
|
||||
|
||||
engine_notes = {
|
||||
|
||||
"rsi_crossover": "Trend-strong filter closes/blocks too aggressively (ema_slope 105 + distance 165). "
|
||||
|
||||
"Raise thresholds or only block entries, not force-exit. Add pip-based scaling per symbol.",
|
||||
|
||||
"rsi_scalp": "High trade count + rsi_against exits cause death by spread. Widen OB/OS gap, add ADX/session "
|
||||
|
||||
"filter, scale trail by ATR not fixed points. Stocks need .NAS symbols.",
|
||||
|
||||
"rsi_asian": "Narrow session + extreme RSI levels -> few trades or bad fills. Align session to broker server "
|
||||
|
||||
"time; add spread cap in points not pips.",
|
||||
|
||||
"mean_reversion": "ADX proxy weak in Python; large min_ema_distance on BTC blocks entries. "
|
||||
|
||||
"Use true ADX; cap concurrent positions; hard SL when ADX escapes.",
|
||||
|
||||
"ema_slope": "Re-enters on same crossover too often; weekly ADX filter missing in audit. "
|
||||
|
||||
"Add cooldown after loss; separate unit vs trail param sets.",
|
||||
|
||||
"darvas": "Box breakout without volume filter whipsaws. Add volume MA + trend MA filter from MQL.",
|
||||
|
||||
"rsi_secret": "Zone re-entry fires too often in chop. Require divergence or RSI momentum confirm.",
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
for eng, rows in by_engine.items():
|
||||
|
||||
sharpes = [x["baseline"]["sharpe"] for x in rows]
|
||||
|
||||
opt_sharpes = [x["optimized"]["sharpe"] for x in rows]
|
||||
|
||||
nets = [x["baseline"]["net_profit"] for x in rows]
|
||||
|
||||
plan["per_engine"][eng] = {
|
||||
|
||||
"count": len(rows),
|
||||
|
||||
"avg_baseline_sharpe": sum(sharpes) / len(sharpes) if sharpes else 0,
|
||||
|
||||
"avg_optimized_sharpe": sum(opt_sharpes) / len(opt_sharpes) if opt_sharpes else 0,
|
||||
|
||||
"avg_baseline_net": sum(nets) / len(nets) if nets else 0,
|
||||
|
||||
"logic_fixes": engine_notes.get(eng, ""),
|
||||
|
||||
"worst_strategies": sorted(rows, key=lambda x: x["baseline"]["sharpe"])[:3],
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
plan["portfolio"] = [
|
||||
|
||||
"Run correlation matrix on daily returns — disable highly correlated RSI scalps on same underlying (NVDA x3).",
|
||||
|
||||
"Portfolio-level max daily loss circuit breaker (pause new entries cluster-wide).",
|
||||
|
||||
"Per-asset-class lot caps: forex micro, gold 0.1, stocks margin-scaled not fixed 25 lots.",
|
||||
|
||||
"Split optimization windows: long 2021-2026 for structure, short 2024-2026 for recency; only deploy params that pass both.",
|
||||
|
||||
"Add core/ShockGuard.mqh: ATR spike pause + margin level gate before SE_TickAll.",
|
||||
|
||||
"Enable robots by regime: Asian RSI only 00-08 server; EMA slope only when W1 ADX > threshold.",
|
||||
|
||||
"Replace fixed magic collisions with 401xxx registry; log per-robot PnL for live attribution.",
|
||||
|
||||
]
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
|
||||
p = argparse.ArgumentParser(description="SuperEA cluster audit with trace logging")
|
||||
|
||||
p.add_argument("trials", nargs="?", type=int, default=80, help="Random trials per strategy (default 80)")
|
||||
|
||||
p.add_argument("--trial-every", type=int, default=10, help="Log every N trials (default 10)")
|
||||
|
||||
p.add_argument("--quiet", action="store_true", help="Suppress trace output")
|
||||
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
args = parse_args()
|
||||
|
||||
global LOG
|
||||
|
||||
LOG = TraceLog(enabled=not args.quiet, trial_every=args.trial_every)
|
||||
|
||||
|
||||
|
||||
total_runs = len(STRATEGIES) * len(PERIODS)
|
||||
|
||||
LOG.banner(
|
||||
|
||||
f"CLUSTER AUDIT - {len(STRATEGIES)} strategies x {len(PERIODS)} periods "
|
||||
|
||||
f"= {total_runs} runs, {args.trials} trials each"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
LOG.phase_start("MT5 initialize")
|
||||
|
||||
if not mt5.initialize():
|
||||
|
||||
LOG.error(f"MT5 init failed: {mt5.last_error()}")
|
||||
|
||||
raise SystemExit(1)
|
||||
|
||||
acc = mt5.account_info()
|
||||
|
||||
if acc:
|
||||
|
||||
LOG.info(f"MT5 connected: server={acc.server} (account redacted)")
|
||||
|
||||
LOG.phase_end("MT5 initialize")
|
||||
|
||||
|
||||
|
||||
out_dir = Path(__file__).parent / "reports"
|
||||
|
||||
out_dir.mkdir(exist_ok=True)
|
||||
|
||||
rng = random.Random(42)
|
||||
|
||||
all_results = []
|
||||
|
||||
run_idx = 0
|
||||
|
||||
errors = 0
|
||||
|
||||
|
||||
|
||||
try:
|
||||
|
||||
for period_label, (start, end) in PERIODS.items():
|
||||
|
||||
LOG.banner(f"PERIOD {period_label} ({start} -> {end})")
|
||||
|
||||
for spec in STRATEGIES:
|
||||
|
||||
run_idx += 1
|
||||
|
||||
LOG.progress(run_idx, total_runs, f"next: {spec['id']}")
|
||||
|
||||
r = run_strategy(spec, period_label, start, end, args.trials, rng, run_idx, total_runs)
|
||||
|
||||
all_results.append(r)
|
||||
|
||||
if "error" in r:
|
||||
|
||||
errors += 1
|
||||
|
||||
|
||||
|
||||
LOG.phase_start("build improvement plan")
|
||||
|
||||
plan = build_improvement_plan(all_results)
|
||||
|
||||
LOG.phase_end("build improvement plan")
|
||||
|
||||
|
||||
|
||||
report = {
|
||||
|
||||
"generated": datetime.now().isoformat(),
|
||||
|
||||
"trials_per_strategy": args.trials,
|
||||
|
||||
"strategies": len(STRATEGIES),
|
||||
|
||||
"periods": list(PERIODS.keys()),
|
||||
|
||||
"runs_total": total_runs,
|
||||
|
||||
"runs_failed": errors,
|
||||
|
||||
"results": all_results,
|
||||
|
||||
"improvement_plan": plan,
|
||||
|
||||
}
|
||||
|
||||
out_path = out_dir / "cluster_audit_report.json"
|
||||
|
||||
out_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
|
||||
LOG.info(f"report written: {out_path} ({out_path.stat().st_size // 1024} KB)")
|
||||
|
||||
|
||||
|
||||
LOG.banner("SUMMARY - worst baseline Sharpe (2021-2026)")
|
||||
|
||||
r21 = [x for x in all_results if x.get("period") == "2021-2026" and "error" not in x]
|
||||
|
||||
for x in sorted(r21, key=lambda z: z["baseline"]["sharpe"])[:10]:
|
||||
|
||||
b = x["baseline"]
|
||||
|
||||
w = b["worst_trades"][:1]
|
||||
|
||||
wtxt = f"${w[0]['profit']:.0f} {w[0]['exit_reason']}" if w else "n/a"
|
||||
|
||||
LOG.info(
|
||||
|
||||
f" {x['id']:28} sharpe={b['sharpe']:6.2f} net=${b['net_profit']:9.0f} "
|
||||
|
||||
f"trades={b['total_trades']:4} worst={wtxt}"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
if errors:
|
||||
|
||||
LOG.warn(f"{errors}/{total_runs} runs failed - search report for \"error\" fields")
|
||||
LOG.banner(f"DONE - {run_idx} runs in {LOG._elapsed()}")
|
||||
|
||||
finally:
|
||||
|
||||
LOG.phase_start("MT5 shutdown")
|
||||
|
||||
mt5.shutdown()
|
||||
|
||||
LOG.phase_end("MT5 shutdown")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
main()
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
United EA MT5 audit: per-strategy solo runs, close-unprofitable A/B, lot/margin check.
|
||||
|
||||
Usage:
|
||||
python -m cluster_audit.run_close_signal_audit
|
||||
python -m cluster_audit.run_close_signal_audit --only DB
|
||||
python -m cluster_audit.run_close_signal_audit --from RS_NVDA --combo
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from set_parser import parse_set_file
|
||||
|
||||
from cluster_audit.united_mt5_manifest import (
|
||||
ALL_ENABLE_KEYS,
|
||||
PARAM_TWEAKS,
|
||||
PRODUCTION_IDS,
|
||||
UNITED_MT5_STRATEGIES,
|
||||
)
|
||||
from cluster_audit.united_mt5_runner import (
|
||||
BASE_SET,
|
||||
deploy_united,
|
||||
mt5_context,
|
||||
patch_set,
|
||||
run_backtest,
|
||||
)
|
||||
|
||||
OUT = Path(__file__).parent / "reports" / "close_signal_audit"
|
||||
LOT_SUMMARY = Path(__file__).parent / "reports" / "lot_genetic" / "lot_genetic_summary.json"
|
||||
REF_BALANCE = 3000.0
|
||||
|
||||
|
||||
def load_best_lots() -> dict[str, float]:
|
||||
if not LOT_SUMMARY.exists():
|
||||
return {}
|
||||
data = json.loads(LOT_SUMMARY.read_text(encoding="utf-8"))
|
||||
return {k: float(v) for k, v in data.get("best_lots", {}).items()}
|
||||
|
||||
|
||||
def solo_patches(spec: dict, *, close_on: bool, lots: dict[str, float]) -> dict:
|
||||
ov = solo_overrides(spec, close_on=close_on)
|
||||
ov["ORCH_ReferenceBalance"] = REF_BALANCE
|
||||
ov["ORCH_ScaleLotsByBalance"] = True
|
||||
if spec.get("lot") in lots:
|
||||
ov[spec["lot"]] = lots[spec["lot"]]
|
||||
return ov
|
||||
|
||||
|
||||
def solo_overrides(target: dict, *, close_on: bool) -> dict[str, bool]:
|
||||
o: dict[str, bool] = {k: False for k in ALL_ENABLE_KEYS}
|
||||
o[target["enable"]] = True
|
||||
o[target["close"]] = close_on
|
||||
o["GAP_Enable"] = False
|
||||
o["OPT_GuardOptimizationMode"] = True
|
||||
return o
|
||||
|
||||
|
||||
def audit_one(ctx: dict, spec: dict, base_params: dict, lots: dict[str, float]) -> dict:
|
||||
sid = spec["id"]
|
||||
print(f"\n{'='*60}\n[{sid}] {spec['name']}\n{'='*60}", flush=True)
|
||||
|
||||
off_ov = solo_patches(spec, close_on=False, lots=lots)
|
||||
on_ov = solo_patches(spec, close_on=True, lots=lots)
|
||||
|
||||
off_body = patch_set(BASE_SET, off_ov)
|
||||
on_body = patch_set(BASE_SET, on_ov)
|
||||
ts = spec.get("test_symbol")
|
||||
|
||||
off = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
off_body, f"solo_{sid}_off.set", f"solo_{sid}_off",
|
||||
test_symbol=ts,
|
||||
)
|
||||
print(f" OFF PF={off.get('profit_factor')} net={off.get('net_profit')} "
|
||||
f"trades={off.get('total_trades')} sharpe={off.get('sharpe')} "
|
||||
f"ready={off.get('ready')} ({off.get('elapsed_sec')}s)", flush=True)
|
||||
|
||||
on = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
on_body, f"solo_{sid}_on.set", f"solo_{sid}_on",
|
||||
test_symbol=ts,
|
||||
)
|
||||
print(f" ON PF={on.get('profit_factor')} net={on.get('net_profit')} "
|
||||
f"trades={on.get('total_trades')} sharpe={on.get('sharpe')} "
|
||||
f"ready={on.get('ready')} ({on.get('elapsed_sec')}s)", flush=True)
|
||||
|
||||
d = delta_metrics(off, on)
|
||||
v = verdict(d, off, on)
|
||||
print(f" -> {v} dNet={d['net_profit_delta']:.0f} dSharpe={d['sharpe_delta']:.3f} "
|
||||
f"dTrades={d['trades_delta']}", flush=True)
|
||||
|
||||
tweaks: list[dict] = []
|
||||
if v == "POSITIVE" and sid in PARAM_TWEAKS:
|
||||
for i, tw in enumerate(PARAM_TWEAKS[sid], 1):
|
||||
ov = {**on_ov, **tw}
|
||||
body = patch_set(BASE_SET, ov)
|
||||
r = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
body, f"solo_{sid}_tw{i}.set", f"solo_{sid}_tw{i}",
|
||||
test_symbol=ts,
|
||||
)
|
||||
tweaks.append({"tweak": tw, "metrics": r})
|
||||
print(f" tweak{i} net={r.get('net_profit')} sharpe={r.get('sharpe')} "
|
||||
f"trades={r.get('total_trades')}", flush=True)
|
||||
|
||||
lot_key = spec["lot"]
|
||||
lot_val = lots.get(lot_key)
|
||||
if lot_val is None and lot_key in base_params:
|
||||
lot_val = base_params[lot_key].value
|
||||
|
||||
return {
|
||||
"id": sid,
|
||||
"name": spec["name"],
|
||||
"enable": spec["enable"],
|
||||
"close_key": spec["close"],
|
||||
"lot_key": lot_key,
|
||||
"lot_value": lot_val,
|
||||
"close_off": off,
|
||||
"close_on": on,
|
||||
"delta": d,
|
||||
"verdict": v,
|
||||
"margin_ok_off": margin_ok(off),
|
||||
"margin_ok_on": margin_ok(on),
|
||||
"param_tweaks": tweaks,
|
||||
"recommend_close_on": v == "POSITIVE",
|
||||
}
|
||||
|
||||
|
||||
def delta_metrics(off: dict, on: dict) -> dict:
|
||||
def g(d, k):
|
||||
v = d.get(k)
|
||||
return v if v is not None else 0
|
||||
|
||||
return {
|
||||
"net_profit_delta": g(on, "net_profit") - g(off, "net_profit"),
|
||||
"pf_delta": (g(on, "profit_factor") - g(off, "profit_factor")),
|
||||
"sharpe_delta": g(on, "sharpe") - g(off, "sharpe"),
|
||||
"trades_delta": g(on, "total_trades") - g(off, "total_trades"),
|
||||
}
|
||||
|
||||
|
||||
def verdict(delta: dict, off: dict, on: dict) -> str:
|
||||
if not off.get("ready") or not on.get("ready"):
|
||||
return "BROKEN"
|
||||
if off.get("total_trades", 0) == 0 and on.get("total_trades", 0) == 0:
|
||||
return "NO_TRADES"
|
||||
if delta["net_profit_delta"] > 50 and delta["sharpe_delta"] >= 0:
|
||||
return "POSITIVE"
|
||||
if delta["net_profit_delta"] < -50 or delta["sharpe_delta"] < -0.1:
|
||||
return "NEGATIVE"
|
||||
return "NEUTRAL"
|
||||
|
||||
|
||||
def margin_ok(m: dict) -> bool:
|
||||
ml = m.get("min_margin_level")
|
||||
if not ml:
|
||||
return True
|
||||
if isinstance(ml, str) and "%" in ml:
|
||||
try:
|
||||
return float(ml.replace("%", "").strip()) >= 100.0
|
||||
except ValueError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def run_combo(ctx: dict, winners: list[dict], base_params: dict) -> dict:
|
||||
"""Combo: enable all strategies that benefit from close-on, with flag set."""
|
||||
overrides: dict = {k: False for k in ALL_ENABLE_KEYS}
|
||||
overrides["GAP_Enable"] = False
|
||||
for w in winners:
|
||||
overrides[w["enable"]] = True
|
||||
overrides[w["close_key"]] = True
|
||||
for spec in UNITED_MT5_STRATEGIES:
|
||||
if spec["enable"] not in overrides or not overrides[spec["enable"]]:
|
||||
continue
|
||||
if spec["id"] not in {w["id"] for w in winners}:
|
||||
overrides[spec["close_key"]] = False
|
||||
|
||||
body = patch_set(BASE_SET, overrides)
|
||||
m = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
body, "combo_close_winners.set", "combo_close_winners",
|
||||
)
|
||||
return {"members": [w["id"] for w in winners], "metrics": m}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--only", default=None)
|
||||
p.add_argument("--from", dest="from_id", default=None)
|
||||
p.add_argument("--production", action="store_true", help="Only PRODUCTION_IDS (19 strategies)")
|
||||
p.add_argument("--combo", action="store_true")
|
||||
p.add_argument("--enabled-only", action="store_true", help="Only strategies enabled in 123.set")
|
||||
args = p.parse_args()
|
||||
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
base_params = parse_set_file(BASE_SET)
|
||||
lots = load_best_lots()
|
||||
sm = {s["id"]: s for s in UNITED_MT5_STRATEGIES}
|
||||
|
||||
if args.production:
|
||||
strategies = [sm[sid] for sid in PRODUCTION_IDS if sid in sm]
|
||||
elif args.enabled_only:
|
||||
strategies = [
|
||||
s for s in UNITED_MT5_STRATEGIES
|
||||
if base_params.get(s["enable"], type("x", (), {"value": False})).value
|
||||
]
|
||||
else:
|
||||
strategies = list(UNITED_MT5_STRATEGIES)
|
||||
|
||||
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"United EA close-signal audit | {len(strategies)} strategies | lots={len(lots)}")
|
||||
import cluster_audit.united_mt5_runner as runner
|
||||
runner.DEPOSIT = int(REF_BALANCE)
|
||||
ctx = mt5_context()
|
||||
print(f"MT5 server={ctx['server']} (account from local terminal)")
|
||||
deploy_united(ctx["data"], ctx["mt5_path"])
|
||||
print("Compiled main.ex5 OK")
|
||||
|
||||
results: list[dict] = []
|
||||
for spec in strategies:
|
||||
try:
|
||||
results.append(audit_one(ctx, spec, base_params, lots))
|
||||
except Exception as ex:
|
||||
print(f" ERROR {spec['id']}: {ex}", flush=True)
|
||||
results.append({"id": spec["id"], "error": str(ex), "verdict": "ERROR"})
|
||||
|
||||
positive = [r for r in results if r.get("verdict") == "POSITIVE"]
|
||||
negative = [r for r in results if r.get("verdict") == "NEGATIVE"]
|
||||
broken = [r for r in results if r.get("verdict") in ("BROKEN", "NO_TRADES", "ERROR")]
|
||||
|
||||
combo_result = None
|
||||
if args.combo and positive:
|
||||
combo_result = run_combo(ctx, positive, base_params)
|
||||
print(f"\nCOMBO winners ({len(positive)}): net={combo_result['metrics'].get('net_profit')} "
|
||||
f"sharpe={combo_result['metrics'].get('sharpe')}")
|
||||
|
||||
summary = {
|
||||
"generated": datetime.now().isoformat(),
|
||||
"base_set": str(BASE_SET),
|
||||
"positive_close_on": [r["id"] for r in positive],
|
||||
"negative_close_on": [r["id"] for r in negative],
|
||||
"broken": [r["id"] for r in broken],
|
||||
"results": results,
|
||||
"combo": combo_result,
|
||||
}
|
||||
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"POSITIVE ({len(positive)}): {[r['id'] for r in positive]}")
|
||||
print(f"NEGATIVE ({len(negative)}): {[r['id'] for r in negative]}")
|
||||
print(f"BROKEN ({len(broken)}): {[r['id'] for r in broken]}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Solo MT5 audit for all currently-disabled sub-strategies (main.mq5 enable=false).
|
||||
|
||||
Finds profitable passers to add to the cluster; compares enhanced portfolio vs production baseline.
|
||||
|
||||
Usage:
|
||||
python -m cluster_audit.run_disabled_audit
|
||||
python -m cluster_audit.run_disabled_audit --min-trades 40
|
||||
"""
|
||||
|
||||
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,
|
||||
PRODUCTION_IDS,
|
||||
UNITED_MT5_STRATEGIES,
|
||||
)
|
||||
from cluster_audit.united_mt5_runner import (
|
||||
BASE_SET,
|
||||
CLUSTER,
|
||||
FROM_DATE,
|
||||
TO_DATE,
|
||||
deploy_united,
|
||||
mt5_context,
|
||||
patch_set,
|
||||
run_backtest,
|
||||
)
|
||||
|
||||
OUT = Path(__file__).resolve().parent / "reports" / "disabled_audit"
|
||||
REF_BALANCE = 3000.0
|
||||
|
||||
|
||||
def g(m: dict, k: str) -> float:
|
||||
v = m.get(k)
|
||||
return float(v) if v is not None else 0.0
|
||||
|
||||
|
||||
def parse_dd_pct(dd: str | None) -> float | None:
|
||||
if not dd:
|
||||
return None
|
||||
m = re.search(r"([\d.]+)\s*%", dd.replace(",", ""))
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def disabled_ids_from_mq5() -> list[str]:
|
||||
text = (CLUSTER / "main.mq5").read_text(encoding="utf-8")
|
||||
off: set[str] = set()
|
||||
for m in re.finditer(r"input bool (Enable\w+) = false", text):
|
||||
off.add(m.group(1))
|
||||
for key in HIGH_MARGIN_STOCK_ENABLES:
|
||||
off.discard(key)
|
||||
ids: list[str] = []
|
||||
for s in UNITED_MT5_STRATEGIES:
|
||||
if s["enable"] in off:
|
||||
ids.append(s["id"])
|
||||
return ids
|
||||
|
||||
|
||||
def common_patches() -> dict[str, float | bool]:
|
||||
return {
|
||||
"ORCH_ReferenceBalance": REF_BALANCE,
|
||||
"ORCH_ScaleLotsByBalance": True,
|
||||
"GAP_Enable": False,
|
||||
"OPT_GuardOptimizationMode": True,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
for key in HIGH_MARGIN_STOCK_ENABLES:
|
||||
o[key] = False
|
||||
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 classify(m: dict, min_trades: int) -> str:
|
||||
if not m.get("ready"):
|
||||
return "BROKEN"
|
||||
trades = int(m.get("total_trades") or 0)
|
||||
if trades < min_trades:
|
||||
return "LOW_TRADES"
|
||||
if g(m, "net_profit") > 0 and g(m, "profit_factor") >= 1.05:
|
||||
return "PASS"
|
||||
if g(m, "net_profit") > 50 and g(m, "profit_factor") >= 1.0:
|
||||
return "MARGINAL"
|
||||
return "FAIL"
|
||||
|
||||
|
||||
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("--min-trades", type=int, default=60)
|
||||
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)
|
||||
|
||||
sm = {s["id"]: s for s in UNITED_MT5_STRATEGIES}
|
||||
disabled = disabled_ids_from_mq5()
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ctx = mt5_context()
|
||||
deploy_united(ctx["data"], ctx["mt5_path"])
|
||||
|
||||
print(
|
||||
f"Disabled audit n={len(disabled)} production={PRODUCTION_IDS} "
|
||||
f"{runner.FROM_DATE}->{runner.TO_DATE}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
prod_ov = {**common_patches(), **production_enables()}
|
||||
baseline = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
patch_set(BASE_SET, prod_ov), "dis_prod_baseline.set", "dis_prod_baseline",
|
||||
)
|
||||
print(
|
||||
f"PROD baseline PF={baseline.get('profit_factor')} net={baseline.get('net_profit')} "
|
||||
f"sharpe={baseline.get('sharpe')} dd={baseline.get('max_drawdown')}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
solo_rows: list[dict] = []
|
||||
passed_ids: list[str] = []
|
||||
|
||||
for sid in disabled:
|
||||
spec = sm[sid]
|
||||
m = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
patch_set(BASE_SET, {**common_patches(), **solo_overrides(spec)}),
|
||||
f"dis_solo_{sid}.set", f"dis_solo_{sid}",
|
||||
test_symbol=spec.get("test_symbol"),
|
||||
)
|
||||
verdict = classify(m, args.min_trades)
|
||||
dd_pct = parse_dd_pct(m.get("max_drawdown"))
|
||||
print(
|
||||
f" {sid:12} {verdict:10} PF={m.get('profit_factor')} net={m.get('net_profit')} "
|
||||
f"sharpe={m.get('sharpe')} trades={m.get('total_trades')} dd={m.get('max_drawdown')}",
|
||||
flush=True,
|
||||
)
|
||||
row = {"id": sid, "enable": spec["enable"], "verdict": verdict, "dd_pct": dd_pct, "metrics": m}
|
||||
solo_rows.append(row)
|
||||
if verdict in ("PASS", "MARGINAL"):
|
||||
passed_ids.append(sid)
|
||||
|
||||
enhanced_ov = dict(prod_ov)
|
||||
for sid in passed_ids:
|
||||
enhanced_ov[sm[sid]["enable"]] = True
|
||||
enhanced = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
patch_set(BASE_SET, enhanced_ov), "dis_enhanced.set", "dis_enhanced",
|
||||
)
|
||||
print(
|
||||
f"ENHANCED +{len(passed_ids)} PF={enhanced.get('profit_factor')} net={enhanced.get('net_profit')} "
|
||||
f"sharpe={enhanced.get('sharpe')} dd={enhanced.get('max_drawdown')}",
|
||||
flush=True,
|
||||
)
|
||||
print(f"PASS/MARGINAL: {passed_ids}", flush=True)
|
||||
|
||||
summary = {
|
||||
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||
"period": {"from": runner.FROM_DATE, "to": runner.TO_DATE},
|
||||
"min_trades": args.min_trades,
|
||||
"disabled_ids": disabled,
|
||||
"production_baseline": baseline,
|
||||
"solo": solo_rows,
|
||||
"passed_ids": passed_ids,
|
||||
"enhanced": enhanced,
|
||||
}
|
||||
path = OUT / "disabled_audit_summary.json"
|
||||
path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||||
print(f"Saved {path}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Symbol mining audit for cluster-latest (round 2 = indices, round 3 = low-margin stocks).
|
||||
|
||||
Baseline = 123.set + survivors only; solo each candidate round; enhanced = baseline + passers.
|
||||
|
||||
Usage:
|
||||
python -m cluster_audit.run_hedge_audit --round 2
|
||||
python -m cluster_audit.run_hedge_audit --round 3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
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,
|
||||
EXPANSION_RETIRED_IDS,
|
||||
HIGH_MARGIN_STOCK_ENABLES,
|
||||
ROUND2_IDS,
|
||||
ROUND3_IDS,
|
||||
SURVIVOR_IDS,
|
||||
UNITED_MT5_STRATEGIES,
|
||||
)
|
||||
from cluster_audit.united_mt5_runner import (
|
||||
BASE_SET,
|
||||
FROM_DATE,
|
||||
TO_DATE,
|
||||
deploy_united,
|
||||
mt5_context,
|
||||
patch_set,
|
||||
run_backtest,
|
||||
)
|
||||
|
||||
OUT = Path(__file__).resolve().parent / "reports" / "hedge_audit"
|
||||
|
||||
MIN_TRADES_DEFAULT = 60
|
||||
|
||||
ROUND_CONFIG = {
|
||||
2: {"candidate_ids": ROUND2_IDS, "prefix": "r2"},
|
||||
3: {"candidate_ids": ROUND3_IDS, "prefix": "r3"},
|
||||
}
|
||||
|
||||
|
||||
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 candidate_ids_for_round(round_num: int) -> tuple[str, ...]:
|
||||
return ROUND_CONFIG[round_num]["candidate_ids"]
|
||||
|
||||
|
||||
def baseline_overrides(round_num: int) -> dict[str, bool]:
|
||||
o: dict[str, bool] = {}
|
||||
retired = set(EXPANSION_RETIRED_IDS)
|
||||
survivors = set(SURVIVOR_IDS)
|
||||
candidates = set(candidate_ids_for_round(round_num))
|
||||
all_candidates = set(ROUND2_IDS) | set(ROUND3_IDS)
|
||||
|
||||
for s in UNITED_MT5_STRATEGIES:
|
||||
sid = s["id"]
|
||||
if sid in retired:
|
||||
o[s["enable"]] = False
|
||||
elif sid in survivors:
|
||||
o[s["enable"]] = True
|
||||
elif sid in all_candidates and sid not in candidates:
|
||||
o[s["enable"]] = False
|
||||
elif sid in candidates:
|
||||
o[s["enable"]] = False
|
||||
|
||||
for key in HIGH_MARGIN_STOCK_ENABLES:
|
||||
o[key] = False
|
||||
|
||||
o["GAP_Enable"] = False
|
||||
o["OPT_GuardOptimizationMode"] = True
|
||||
return o
|
||||
|
||||
|
||||
def solo_overrides(spec: dict) -> dict:
|
||||
o: dict[str, bool] = {k: False for k in ALL_ENABLE_KEYS}
|
||||
o[spec["enable"]] = True
|
||||
for key in HIGH_MARGIN_STOCK_ENABLES:
|
||||
o[key] = False
|
||||
o["GAP_Enable"] = False
|
||||
o["OPT_GuardOptimizationMode"] = True
|
||||
return o
|
||||
|
||||
|
||||
def classify_solo(m: dict, min_trades: int) -> str:
|
||||
if not m.get("ready"):
|
||||
return "BROKEN"
|
||||
trades = int(m.get("total_trades", 0) or 0)
|
||||
if trades < min_trades:
|
||||
return "LOW_TRADES"
|
||||
if g(m, "net_profit") > 0 and g(m, "profit_factor") >= 1.05:
|
||||
return "PASS"
|
||||
if g(m, "net_profit") > 50 and g(m, "profit_factor") >= 1.0:
|
||||
return "MARGINAL"
|
||||
return "FAIL"
|
||||
|
||||
|
||||
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 portfolio_verdict(d: dict) -> str:
|
||||
if d["net_profit_delta"] > 100 and d["sharpe_delta"] >= -0.05:
|
||||
return "IMPROVED"
|
||||
if d["net_profit_delta"] < -150 or d["sharpe_delta"] < -0.15:
|
||||
return "WORSE"
|
||||
return "NEUTRAL"
|
||||
|
||||
|
||||
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("--min-trades", type=int, default=MIN_TRADES_DEFAULT)
|
||||
p.add_argument("--round", type=int, default=3, choices=(2, 3))
|
||||
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("-", ".")
|
||||
|
||||
cfg = ROUND_CONFIG[args.round]
|
||||
prefix = cfg["prefix"]
|
||||
candidates = cfg["candidate_ids"]
|
||||
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
ctx = mt5_context()
|
||||
deploy_united(ctx["data"], ctx["mt5_path"])
|
||||
|
||||
sm = spec_map()
|
||||
print(
|
||||
f"Round {args.round} {runner.FROM_DATE}->{runner.TO_DATE} "
|
||||
f"min_trades={args.min_trades} survivor={SURVIVOR_IDS} "
|
||||
f"candidates={candidates}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
baseline = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
patch_set(BASE_SET, baseline_overrides(args.round)),
|
||||
f"{prefix}_baseline.set", f"{prefix}_baseline",
|
||||
)
|
||||
print(
|
||||
f"BASELINE PF={baseline.get('profit_factor')} net={baseline.get('net_profit')} "
|
||||
f"sharpe={baseline.get('sharpe')} trades={baseline.get('total_trades')}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
solo_results: list[dict] = []
|
||||
passed: list[str] = []
|
||||
|
||||
for sid in candidates:
|
||||
spec = sm[sid]
|
||||
m = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
patch_set(BASE_SET, solo_overrides(spec)),
|
||||
f"{prefix}_solo_{sid}.set", f"{prefix}_solo_{sid}",
|
||||
test_symbol=spec.get("test_symbol"),
|
||||
)
|
||||
verdict = classify_solo(m, args.min_trades)
|
||||
print(
|
||||
f"SOLO {sid:10} {verdict:10} PF={m.get('profit_factor')} net={m.get('net_profit')} "
|
||||
f"sharpe={m.get('sharpe')} trades={m.get('total_trades')}",
|
||||
flush=True,
|
||||
)
|
||||
solo_results.append({"id": sid, "verdict": verdict, "metrics": m})
|
||||
if verdict in ("PASS", "MARGINAL"):
|
||||
passed.append(spec["enable"])
|
||||
|
||||
enhanced_ov = baseline_overrides(args.round)
|
||||
for sid in candidates:
|
||||
spec = sm[sid]
|
||||
if spec["enable"] in passed:
|
||||
enhanced_ov[spec["enable"]] = True
|
||||
|
||||
enhanced = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
patch_set(BASE_SET, enhanced_ov),
|
||||
f"{prefix}_enhanced.set", f"{prefix}_enhanced",
|
||||
)
|
||||
d = delta(baseline, enhanced)
|
||||
pv = portfolio_verdict(d)
|
||||
print(
|
||||
f"ENHANCED {pv} PF={enhanced.get('profit_factor')} net={enhanced.get('net_profit')} "
|
||||
f"sharpe={enhanced.get('sharpe')} trades={enhanced.get('total_trades')} "
|
||||
f"dNet={d['net_profit_delta']:.0f} dSharpe={d['sharpe_delta']:.3f}",
|
||||
flush=True,
|
||||
)
|
||||
print(f"PASSED ({len(passed)}): {', '.join(passed)}", flush=True)
|
||||
|
||||
summary = {
|
||||
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||
"round": args.round,
|
||||
"survivors": list(SURVIVOR_IDS),
|
||||
"candidates": list(candidates),
|
||||
"high_margin_disabled": list(HIGH_MARGIN_STOCK_ENABLES),
|
||||
"period": {"from": runner.FROM_DATE, "to": runner.TO_DATE},
|
||||
"min_trades": args.min_trades,
|
||||
"baseline": baseline,
|
||||
"solo": solo_results,
|
||||
"passed_enables": passed,
|
||||
"enhanced": enhanced,
|
||||
"delta": d,
|
||||
"portfolio_verdict": pv,
|
||||
}
|
||||
out_path = OUT / f"round{args.round}_audit_summary.json"
|
||||
out_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||||
print(f"Saved {out_path}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MT5 genetic lot optimization — one production sub-strategy at a time.
|
||||
|
||||
Ranges:
|
||||
stock → 5..15 step 5
|
||||
other → 0.01..0.1 step 0.01
|
||||
|
||||
Usage:
|
||||
python -m cluster_audit.run_lot_genetic
|
||||
python -m cluster_audit.run_lot_genetic --only RS_NVDA
|
||||
python -m cluster_audit.run_lot_genetic --apply
|
||||
python -m cluster_audit.run_lot_genetic --resume # skip ids already in summary
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
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_GENETIC_RANGE,
|
||||
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,
|
||||
patch_set_for_lot_genetic,
|
||||
run_backtest,
|
||||
run_genetic_lot_optimize,
|
||||
)
|
||||
|
||||
OUT = Path(__file__).resolve().parent / "reports" / "lot_genetic"
|
||||
REF_BALANCE = 3000.0
|
||||
SUMMARY_PATH = OUT / "lot_genetic_summary.json"
|
||||
|
||||
|
||||
def lot_class(sid: str) -> str:
|
||||
return LOT_CLASS_BY_ID.get(sid, "forex")
|
||||
|
||||
|
||||
def genetic_range(sid: str) -> tuple[float, float, float]:
|
||||
if lot_class(sid) == "stock":
|
||||
return LOT_GENETIC_RANGE["stock"]
|
||||
return LOT_GENETIC_RANGE["default"]
|
||||
|
||||
|
||||
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 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_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, _ = re.subn(
|
||||
rf"(input double {re.escape(key)} = )[0-9.]+;",
|
||||
rf"\g<1>{sval};",
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
text, _ = re.subn(
|
||||
r"(input double ORCH_ReferenceBalance = )[0-9.]+;",
|
||||
rf"\g<1>{REF_BALANCE};",
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
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)
|
||||
return "\n".join(lines_out) + "\n"
|
||||
|
||||
|
||||
def load_summary() -> dict:
|
||||
if SUMMARY_PATH.exists():
|
||||
return json.loads(SUMMARY_PATH.read_text(encoding="utf-8"))
|
||||
return {"results": [], "best_lots": {}}
|
||||
|
||||
|
||||
def save_summary(summary: dict) -> None:
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
SUMMARY_PATH.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def run_backtest_with_retry(
|
||||
ctx: dict,
|
||||
set_body: str,
|
||||
set_name: str,
|
||||
report: str,
|
||||
*,
|
||||
test_symbol: str | None = None,
|
||||
retries: int = 3,
|
||||
) -> dict:
|
||||
last: dict = {"ready": False}
|
||||
for attempt in range(retries):
|
||||
if attempt:
|
||||
time.sleep(12)
|
||||
last = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
set_body, set_name, report, test_symbol=test_symbol,
|
||||
)
|
||||
if last.get("ready"):
|
||||
return last
|
||||
return last
|
||||
|
||||
|
||||
def lot_report_tag(lot: float) -> str:
|
||||
return str(int(lot)) if lot == int(lot) else str(lot).replace(".", "p")
|
||||
|
||||
|
||||
def optimize_one(ctx: dict, spec: dict, *, opt_mode: int, grid_only: bool) -> dict:
|
||||
sid = spec["id"]
|
||||
lot_key = spec["lot"]
|
||||
start, step, stop = genetic_range(sid)
|
||||
ov = {**common_patches(), **solo_overrides(spec)}
|
||||
report = f"lotgen_{sid}"
|
||||
|
||||
print(
|
||||
f"\n[{sid}] lot sweep {lot_key} range={start}..{stop} step={step} "
|
||||
f"symbol={spec.get('test_symbol') or 'NAS100'}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if not grid_only:
|
||||
body = patch_set_for_lot_genetic(BASE_SET, ov, lot_key, start, step, stop)
|
||||
m = run_genetic_lot_optimize(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
body, f"{report}.set", report, lot_key,
|
||||
test_symbol=spec.get("test_symbol"),
|
||||
optimization=opt_mode,
|
||||
)
|
||||
best_lot = m.get("best_lot")
|
||||
if m.get("ready") and best_lot is not None:
|
||||
print(
|
||||
f" BEST lot={best_lot} PF={m.get('profit_factor')} net={m.get('profit')} "
|
||||
f"sharpe={m.get('sharpe')} trades={m.get('trades')} passes={m.get('passes')} "
|
||||
f"({m.get('elapsed_sec')}s)",
|
||||
flush=True,
|
||||
)
|
||||
return {
|
||||
"id": sid,
|
||||
"lot_key": lot_key,
|
||||
"lot_class": lot_class(sid),
|
||||
"range": {"start": start, "step": step, "stop": stop},
|
||||
"best_lot": best_lot,
|
||||
"metrics": m,
|
||||
}
|
||||
print(f" genetic XML miss ({m.get('error')}) — grid sweep", flush=True)
|
||||
|
||||
from cluster_audit.united_mt5_manifest import LOT_GRIDS
|
||||
|
||||
grid = LOT_GRIDS["stock"] if lot_class(sid) == "stock" else LOT_GRIDS["forex"]
|
||||
best_sc, best_lot, best_m = -1e18, grid[0], {}
|
||||
t0 = time.time()
|
||||
for lot in grid:
|
||||
tag = lot_report_tag(lot)
|
||||
ov2 = {**ov, lot_key: lot}
|
||||
bm = run_backtest_with_retry(
|
||||
ctx,
|
||||
patch_set(BASE_SET, ov2), f"lot_{sid}_{tag}.set", f"lot_{sid}_{tag}",
|
||||
test_symbol=spec.get("test_symbol"),
|
||||
)
|
||||
if not bm.get("ready"):
|
||||
print(f" lot={lot} FAILED (no report)", flush=True)
|
||||
continue
|
||||
trades = int(bm.get("total_trades") or 0)
|
||||
profit = float(bm.get("net_profit") or 0)
|
||||
pf = float(bm.get("profit_factor") or 0)
|
||||
sharpe = float(bm.get("sharpe") or 0)
|
||||
if trades < 20 or pf < 1.0 or profit <= 0:
|
||||
sc = -1e10 + profit
|
||||
else:
|
||||
sc = sharpe * 2000 + profit / 500 + pf * 50
|
||||
print(
|
||||
f" lot={lot} PF={pf} net={profit} sharpe={sharpe} trades={trades}",
|
||||
flush=True,
|
||||
)
|
||||
if sc > best_sc:
|
||||
best_sc, best_lot, best_m = sc, lot, bm
|
||||
elapsed = round(time.time() - t0, 1)
|
||||
best_m = {**best_m, "best_lot": best_lot, "method": "grid", "elapsed_sec": elapsed}
|
||||
print(
|
||||
f" BEST lot={best_lot} PF={best_m.get('profit_factor')} net={best_m.get('net_profit')} "
|
||||
f"sharpe={best_m.get('sharpe')} trades={best_m.get('total_trades')} ({elapsed}s)",
|
||||
flush=True,
|
||||
)
|
||||
return {
|
||||
"id": sid,
|
||||
"lot_key": lot_key,
|
||||
"lot_class": lot_class(sid),
|
||||
"range": {"start": start, "step": step, "stop": stop},
|
||||
"best_lot": best_lot,
|
||||
"metrics": best_m,
|
||||
}
|
||||
|
||||
|
||||
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=[])
|
||||
p.add_argument("--apply", action="store_true")
|
||||
p.add_argument("--resume", action="store_true", help="Skip strategies already in summary")
|
||||
p.add_argument("--redo", action="append", default=[], help="Re-run these ids even if in summary")
|
||||
p.add_argument("--mode", choices=("genetic", "complete", "grid"), default="grid",
|
||||
help="grid=direct lot sweep (default); genetic=try MT5 genetic first")
|
||||
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)
|
||||
|
||||
opt_mode = 2 if args.mode == "genetic" else 1
|
||||
grid_only = args.mode == "grid"
|
||||
ids = args.only if args.only else list(PRODUCTION_IDS)
|
||||
sm = {s["id"]: s for s in UNITED_MT5_STRATEGIES}
|
||||
|
||||
summary = load_summary() if args.resume else {"results": [], "best_lots": {}}
|
||||
done_ids = set()
|
||||
if args.resume:
|
||||
for r in summary.get("results", []):
|
||||
m = r.get("metrics") or {}
|
||||
if m.get("ready") and r["id"] not in args.redo:
|
||||
done_ids.add(r["id"])
|
||||
|
||||
ctx = mt5_context()
|
||||
deploy_united(ctx["data"], ctx["mt5_path"])
|
||||
|
||||
print(
|
||||
f"Lot genetic deposit={DEPOSIT} ref={REF_BALANCE} "
|
||||
f"{runner.FROM_DATE}->{runner.TO_DATE} mode={args.mode} n={len(ids)}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
for sid in ids:
|
||||
if sid not in sm:
|
||||
print(f"skip unknown {sid}", flush=True)
|
||||
continue
|
||||
if sid in done_ids and sid not in args.redo:
|
||||
print(f"skip done {sid}", flush=True)
|
||||
continue
|
||||
r = optimize_one(ctx, sm[sid], opt_mode=opt_mode, grid_only=grid_only)
|
||||
summary["results"] = [x for x in summary.get("results", []) if x["id"] != sid] + [r]
|
||||
summary["best_lots"][r["lot_key"]] = r["best_lot"]
|
||||
summary["timestamp"] = datetime.now().isoformat(timespec="seconds")
|
||||
summary["period"] = {"from": runner.FROM_DATE, "to": runner.TO_DATE}
|
||||
save_summary(summary)
|
||||
|
||||
print(f"\nSaved {SUMMARY_PATH}", flush=True)
|
||||
for r in summary["results"]:
|
||||
print(
|
||||
f" {r['id']:12} {r['lot_key']}={r['best_lot']} "
|
||||
f"PF={r['metrics'].get('profit_factor')} sharpe={r['metrics'].get('sharpe')}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if args.apply and summary.get("best_lots"):
|
||||
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"), summary["best_lots"]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
set_path.write_text(
|
||||
apply_lots_to_set_text(set_path.read_text(encoding="utf-8"), summary["best_lots"]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"Applied to {mq5_path} and {set_path}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lot-opt new production strategies, merge keepers, apply, combined backtest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from cluster_audit.run_lot_genetic import (
|
||||
SUMMARY_PATH,
|
||||
apply_lots_to_mq5,
|
||||
apply_lots_to_set_text,
|
||||
common_patches,
|
||||
optimize_one,
|
||||
save_summary,
|
||||
)
|
||||
|
||||
from cluster_audit.united_mt5_manifest import UNITED_MT5_STRATEGIES
|
||||
from cluster_audit.united_mt5_runner import (
|
||||
BASE_SET,
|
||||
CLUSTER,
|
||||
deploy_united,
|
||||
mt5_context,
|
||||
patch_set,
|
||||
run_backtest,
|
||||
)
|
||||
|
||||
# Lots from prior genetic run (unchanged strategies)
|
||||
KEEPER_LOTS: dict[str, float] = {
|
||||
"LOT_DB_DarvasBox": 0.01,
|
||||
"LOT_ES_EMASlopeDistance": 0.07,
|
||||
"LOT_RC_RSICrossOver": 0.1,
|
||||
"LOT_RM_RSIMidPointHijack": 0.01,
|
||||
"LOT_RS_NVDA": 5.0,
|
||||
"LOT_RS_TSLA": 5.0,
|
||||
"LOT_RRA_AUDUSD": 0.05,
|
||||
"LOT_UB_USDJPY": 0.03,
|
||||
"LOT_RS_NAS100": 0.03,
|
||||
"LOT_UKB_UK100": 0.01,
|
||||
}
|
||||
|
||||
NEW_OPT_IDS = (
|
||||
"RS_BTCUSD", "RS_XAUUSD", "SE", "ST_BTC", "ST_XAU",
|
||||
"RRA_GBP", "GB", "U5B", "RS_US30",
|
||||
)
|
||||
|
||||
|
||||
def production_enables() -> dict[str, bool]:
|
||||
from cluster_audit.united_mt5_manifest import ALL_ENABLE_KEYS, HIGH_MARGIN_STOCK_ENABLES, PRODUCTION_IDS
|
||||
prod = set(PRODUCTION_IDS)
|
||||
o = {s["enable"]: s["id"] in prod for s in UNITED_MT5_STRATEGIES}
|
||||
for k in HIGH_MARGIN_STOCK_ENABLES:
|
||||
o[k] = False
|
||||
return o
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sm = {s["id"]: s for s in UNITED_MT5_STRATEGIES}
|
||||
summary = {"results": [], "best_lots": dict(KEEPER_LOTS), "timestamp": datetime.now().isoformat(timespec="seconds")}
|
||||
save_summary(summary)
|
||||
|
||||
ctx = mt5_context()
|
||||
deploy_united(ctx["data"], ctx["mt5_path"])
|
||||
|
||||
print(f"Optimizing {len(NEW_OPT_IDS)} new/changed strategies...", flush=True)
|
||||
for sid in NEW_OPT_IDS:
|
||||
r = optimize_one(ctx, sm[sid], opt_mode=1, grid_only=True)
|
||||
summary["results"].append(r)
|
||||
summary["best_lots"][r["lot_key"]] = r["best_lot"]
|
||||
save_summary(summary)
|
||||
print(f" {sid} -> {r['lot_key']}={r['best_lot']}", flush=True)
|
||||
|
||||
# Apply all production lots
|
||||
lots = summary["best_lots"]
|
||||
mq5 = CLUSTER / "main.mq5"
|
||||
st = CLUSTER / "123.set"
|
||||
mq5.write_text(apply_lots_to_mq5(mq5.read_text(encoding="utf-8"), lots), encoding="utf-8")
|
||||
st.write_text(apply_lots_to_set_text(st.read_text(encoding="utf-8"), lots), encoding="utf-8")
|
||||
print(f"Applied {len(lots)} lots to main.mq5 + 123.set", flush=True)
|
||||
|
||||
ov = {**common_patches(), **production_enables(), **lots}
|
||||
m = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
patch_set(BASE_SET, ov), "prod_v2_combined.set", "prod_v2_combined",
|
||||
)
|
||||
print(
|
||||
f"\nCOMBINED v2 PF={m.get('profit_factor')} net={m.get('net_profit')} "
|
||||
f"sharpe={m.get('sharpe')} trades={m.get('total_trades')} dd={m.get('max_drawdown')}",
|
||||
flush=True,
|
||||
)
|
||||
summary["combined_v2"] = m
|
||||
save_summary(summary)
|
||||
print(f"Saved {SUMMARY_PATH}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Sequential cluster audit: one strategy at a time, fix until it passes, then next.
|
||||
|
||||
Pass criteria (optimized result):
|
||||
- >= 1 trade per calendar day over the backtest window (~1977 for 2021-2026)
|
||||
- net > 0, profit factor >= 1.15, sharpe >= 0.3
|
||||
- winning months >= 45%, drawdown <= 25%
|
||||
|
||||
Usage:
|
||||
python -m cluster_audit.run_sequential [trials] [--only ID] [--from ID]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from cluster_audit.backtest_core import CostModel, load_bars, resolve_symbol
|
||||
from cluster_audit.diagnose import diagnose
|
||||
from cluster_audit.engines import ENGINE_MAP
|
||||
from cluster_audit.portfolio_build import build_progressive_portfolios
|
||||
from cluster_audit.run_audit import _sample_params
|
||||
from cluster_audit.scoring import (
|
||||
DEFAULT_TRADES_PER_DAY,
|
||||
acceptance,
|
||||
format_quality_line,
|
||||
min_trades_for_period,
|
||||
period_days,
|
||||
score_label,
|
||||
score_report,
|
||||
trades_per_day,
|
||||
)
|
||||
from cluster_audit.strategy_registry import PERIODS, STRATEGIES, TF
|
||||
from cluster_audit.trace_log import TraceLog
|
||||
|
||||
LOG = TraceLog(enabled=True, trial_every=10)
|
||||
PRIMARY_PERIOD = "2021-2026"
|
||||
|
||||
|
||||
def run_one_strategy(
|
||||
spec: dict,
|
||||
period_label: str,
|
||||
start: str,
|
||||
end: str,
|
||||
trials: int,
|
||||
rng: random.Random,
|
||||
idx: int,
|
||||
total: int,
|
||||
days: int,
|
||||
trades_per_day_target: float,
|
||||
) -> dict:
|
||||
sid = spec["id"]
|
||||
tf_key = spec["tf"]
|
||||
label = f"[{idx}/{total}] {sid} @ {period_label}"
|
||||
LOG.banner(label)
|
||||
|
||||
engine = ENGINE_MAP[spec["engine"]]
|
||||
sym = resolve_symbol(spec["symbol"])
|
||||
start_dt = datetime.fromisoformat(start)
|
||||
end_dt = datetime.fromisoformat(end)
|
||||
|
||||
try:
|
||||
df = load_bars(sym, TF[tf_key], start_dt, end_dt)
|
||||
except Exception as e:
|
||||
LOG.error(f"data load failed: {e}")
|
||||
return {"id": sid, "period": period_label, "error": str(e), "spec": spec, "passed": False}
|
||||
|
||||
LOG.info(f"loaded {len(df)} bars symbol={sym} engine={spec['engine']}")
|
||||
costs = CostModel.for_symbol(sym)
|
||||
defaults = dict(spec["defaults"])
|
||||
|
||||
min_t = min_trades_for_period(days, trades_per_day_target)
|
||||
LOG.info(f"activity gate: >={min_t} trades ({trades_per_day_target:.1f}/day x {days}d)")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
baseline = engine(df, sym, period_label, sid, defaults, spec["lot"], costs)
|
||||
LOG.info(f"baseline: {format_quality_line(baseline, days)} ({(time.perf_counter()-t0)*1000:.0f}ms)")
|
||||
|
||||
best = baseline
|
||||
best_params = defaults
|
||||
best_score = score_report(baseline, days, trades_per_day_target)
|
||||
|
||||
base_ok, base_issues = acceptance(baseline, days, trades_per_day_target)
|
||||
if base_ok:
|
||||
LOG.info("baseline PASSES acceptance gates")
|
||||
else:
|
||||
LOG.warn("baseline fails: " + "; ".join(base_issues))
|
||||
|
||||
if trials > 0:
|
||||
LOG.info(f"optimizing {trials} trials (score requires trades + profit + consistency)...")
|
||||
for n in range(1, trials + 1):
|
||||
params = _sample_params(defaults, spec.get("opt", {}), rng)
|
||||
r = engine(df, sym, period_label, sid, params, spec["lot"], costs)
|
||||
sc = score_report(r, days, trades_per_day_target)
|
||||
if sc > best_score:
|
||||
best_score = sc
|
||||
best = r
|
||||
best_params = params
|
||||
LOG.trial(n, trials, sc, r.net_profit, r.sharpe, improved=True)
|
||||
LOG.debug(f" -> {format_quality_line(r, days)}")
|
||||
elif n % LOG.trial_every == 0 or n == trials:
|
||||
LOG.trial(n, trials, sc, r.net_profit, r.sharpe, improved=False)
|
||||
|
||||
LOG.info(f"optimized: {format_quality_line(best, days)}")
|
||||
opt_ok, opt_issues = acceptance(best, days, trades_per_day_target)
|
||||
passed = opt_ok or base_ok
|
||||
|
||||
if passed:
|
||||
LOG.info(f"PASS {sid} - ready for portfolio")
|
||||
else:
|
||||
LOG.warn(f"FAIL {sid} - needs engine/logic work before next strategy")
|
||||
LOG.warn(" " + "; ".join(opt_issues if not opt_ok else base_issues))
|
||||
|
||||
diag = diagnose(spec, baseline, best, LOG)
|
||||
|
||||
result = {
|
||||
"id": sid,
|
||||
"engine": spec["engine"],
|
||||
"symbol": sym,
|
||||
"timeframe": tf_key,
|
||||
"period": period_label,
|
||||
"bars": len(df),
|
||||
"period_days": days,
|
||||
"trades_per_day_target": trades_per_day_target,
|
||||
"baseline_trades_per_day": trades_per_day(baseline, days),
|
||||
"optimized_trades_per_day": trades_per_day(best, days),
|
||||
"passed": passed,
|
||||
"acceptance_issues": opt_issues if not opt_ok else ([] if base_ok else base_issues),
|
||||
"baseline": baseline.to_dict(),
|
||||
"optimized": {**best.to_dict(), "params": best_params},
|
||||
"optimized_params": best_params,
|
||||
"optimized_score": best_score,
|
||||
"improvement_net": best.net_profit - baseline.net_profit,
|
||||
"improvement_trades": best.total_trades - baseline.total_trades,
|
||||
"diagnosis": diag,
|
||||
"spec": spec,
|
||||
}
|
||||
|
||||
out_dir = Path(__file__).parent / "reports" / "sequential"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / f"{sid}_{period_label}.json"
|
||||
path.write_text(json.dumps({k: v for k, v in result.items() if k != "spec"}, indent=2), encoding="utf-8")
|
||||
LOG.info(f"saved {path.name}")
|
||||
|
||||
try:
|
||||
from cluster_audit.sync_cluster import main as sync_cluster
|
||||
sync_cluster()
|
||||
LOG.info("cluster-latest SuperEA_AuditParams.mqh updated")
|
||||
except Exception as ex:
|
||||
LOG.warn(f"cluster sync skipped: {ex}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Sequential cluster audit - fix each before next")
|
||||
p.add_argument("trials", nargs="?", type=int, default=40)
|
||||
p.add_argument("--portfolio-trials", type=int, default=30)
|
||||
p.add_argument("--from", dest="from_id", default=None)
|
||||
p.add_argument("--only", default=None)
|
||||
p.add_argument("--skip-portfolio", action="store_true")
|
||||
p.add_argument("--trial-every", type=int, default=10)
|
||||
p.add_argument("--trades-per-day", type=float, default=DEFAULT_TRADES_PER_DAY)
|
||||
p.add_argument("--continue-on-fail", action="store_true", help="Run next strategy even if current fails")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
global LOG
|
||||
LOG = TraceLog(enabled=True, trial_every=args.trial_every)
|
||||
|
||||
start, end = PERIODS[PRIMARY_PERIOD]
|
||||
days = period_days(datetime.fromisoformat(start), datetime.fromisoformat(end))
|
||||
strategies = STRATEGIES
|
||||
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
|
||||
|
||||
LOG.banner(
|
||||
f"SEQUENTIAL AUDIT - {len(strategies)} strategies | "
|
||||
f">={args.trades_per_day:.1f} trade/day (~{min_trades_for_period(days, args.trades_per_day)} trades) | "
|
||||
f"{args.trials} opt trials"
|
||||
)
|
||||
|
||||
if not mt5.initialize():
|
||||
LOG.error(f"MT5 init failed: {mt5.last_error()}")
|
||||
raise SystemExit(1)
|
||||
|
||||
out_dir = Path(__file__).parent / "reports" / "sequential"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
rng = random.Random(42)
|
||||
results: list[dict] = []
|
||||
passed_ids: list[str] = []
|
||||
failed_ids: list[str] = []
|
||||
|
||||
try:
|
||||
for i, spec in enumerate(strategies, 1):
|
||||
r = run_one_strategy(
|
||||
spec, PRIMARY_PERIOD, start, end, args.trials, rng, i, len(strategies),
|
||||
days, args.trades_per_day,
|
||||
)
|
||||
results.append(r)
|
||||
if r.get("passed"):
|
||||
passed_ids.append(r["id"])
|
||||
elif "error" not in r:
|
||||
failed_ids.append(r["id"])
|
||||
if not args.continue_on_fail and not args.only:
|
||||
LOG.warn(f"STOPPED at {r['id']} - fix engine/params then resume with --from {r['id']}")
|
||||
break
|
||||
|
||||
valid = [r for r in results if r.get("passed") and "error" not in r]
|
||||
valid.sort(key=lambda x: x.get("optimized_score", float("-inf")), reverse=True)
|
||||
|
||||
summary = {
|
||||
"generated": datetime.now().isoformat(),
|
||||
"period_days": days,
|
||||
"trades_per_day_target": args.trades_per_day,
|
||||
"trials": args.trials,
|
||||
"passed": passed_ids,
|
||||
"failed": failed_ids,
|
||||
"ranking": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"score": r.get("optimized_score"),
|
||||
"net": r["optimized"]["net_profit"],
|
||||
"trades": r["optimized"]["total_trades"],
|
||||
"sharpe": r["optimized"]["sharpe"],
|
||||
"pf": r["optimized"]["profit_factor"],
|
||||
}
|
||||
for r in valid
|
||||
],
|
||||
"results": [{k: v for k, v in r.items() if k != "spec"} for r in results],
|
||||
}
|
||||
|
||||
if not args.skip_portfolio and valid:
|
||||
LOG.banner("PROGRESSIVE PORTFOLIO BUILD (passed strategies only)")
|
||||
ranked = [{"spec": r["spec"], "optimized_params": r["optimized_params"]} for r in valid]
|
||||
summary["portfolio_steps"] = build_progressive_portfolios(
|
||||
ranked, PRIMARY_PERIOD, start, end, args.portfolio_trials, rng, LOG
|
||||
)
|
||||
|
||||
(out_dir / "sequential_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||||
|
||||
LOG.banner(f"PASSED {len(passed_ids)} / FAILED {len(failed_ids)}")
|
||||
for r in valid:
|
||||
o = r["optimized"]
|
||||
LOG.info(
|
||||
f" {r['id']:28} score={score_label(r['optimized_score'], o['total_trades'], days, args.trades_per_day):>22} "
|
||||
f"net=${o['net_profit']:9.0f} trades={o['total_trades']:5} ({r.get('optimized_trades_per_day', 0):.2f}/day)"
|
||||
)
|
||||
for fid in failed_ids:
|
||||
LOG.warn(f" NEEDS FIX: {fid}")
|
||||
|
||||
LOG.banner(f"DONE in {LOG._elapsed()}")
|
||||
finally:
|
||||
mt5.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,428 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Sequential United EA audit (main.mq5 strategies from 123.set).
|
||||
|
||||
Usage:
|
||||
python -m cluster_audit.run_united_sequential [trials] [--only ID] [--from ID] [--continue-on-fail]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from cluster_audit.run_sequential import run_one_strategy # noqa: E402
|
||||
from cluster_audit.scoring import ( # noqa: E402
|
||||
DEFAULT_TRADES_PER_DAY,
|
||||
min_trades_for_period,
|
||||
period_days,
|
||||
score_label,
|
||||
)
|
||||
from cluster_audit.trace_log import TraceLog # noqa: E402
|
||||
from cluster_audit.united_registry import PERIODS, UNITED_STRATEGIES # noqa: E402
|
||||
|
||||
PRIMARY_PERIOD = "2021-2026"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="United EA sequential audit (main.mq5)")
|
||||
p.add_argument("trials", nargs="?", type=int, default=80)
|
||||
p.add_argument("--from", dest="from_id", default=None)
|
||||
p.add_argument("--only", default=None)
|
||||
p.add_argument("--trial-every", type=int, default=10)
|
||||
p.add_argument("--trades-per-day", type=float, default=DEFAULT_TRADES_PER_DAY)
|
||||
p.add_argument("--continue-on-fail", action="store_true")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
log = TraceLog(enabled=True, trial_every=args.trial_every)
|
||||
|
||||
start, end = PERIODS[PRIMARY_PERIOD]
|
||||
days = period_days(datetime.fromisoformat(start), datetime.fromisoformat(end))
|
||||
strategies = UNITED_STRATEGIES
|
||||
if args.only:
|
||||
strategies = [s for s in UNITED_STRATEGIES if s["id"] == args.only]
|
||||
elif args.from_id:
|
||||
found = False
|
||||
filtered = []
|
||||
for s in UNITED_STRATEGIES:
|
||||
if s["id"] == args.from_id:
|
||||
found = True
|
||||
if found:
|
||||
filtered.append(s)
|
||||
strategies = filtered if found else UNITED_STRATEGIES
|
||||
|
||||
log.banner(
|
||||
f"UNITED EA AUDIT - {len(strategies)} strategies | "
|
||||
f">={args.trades_per_day:.1f} trade/day | {args.trials} trials"
|
||||
)
|
||||
|
||||
if not mt5.initialize():
|
||||
log.error(f"MT5 init failed: {mt5.last_error()}")
|
||||
raise SystemExit(1)
|
||||
|
||||
out_dir = Path(__file__).parent / "reports" / "united_sequential"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
rng = random.Random(42)
|
||||
results: list[dict] = []
|
||||
passed_ids: list[str] = []
|
||||
failed_ids: list[str] = []
|
||||
|
||||
try:
|
||||
for i, spec in enumerate(strategies, 1):
|
||||
r = run_one_strategy(
|
||||
spec, PRIMARY_PERIOD, start, end, args.trials, rng, i, len(strategies),
|
||||
days, args.trades_per_day,
|
||||
)
|
||||
# relocate report to united folder
|
||||
src = Path(__file__).parent / "reports" / "sequential" / f"{spec['id']}_{PRIMARY_PERIOD}.json"
|
||||
dst = out_dir / f"{spec['id']}_{PRIMARY_PERIOD}.json"
|
||||
if src.exists():
|
||||
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
results.append(r)
|
||||
if r.get("passed"):
|
||||
passed_ids.append(r["id"])
|
||||
elif "error" not in r:
|
||||
failed_ids.append(r["id"])
|
||||
if not args.continue_on_fail and not args.only:
|
||||
log.warn(f"STOPPED at {r['id']} — resume with --from {r['id']} --continue-on-fail")
|
||||
break
|
||||
|
||||
summary = {
|
||||
"generated": datetime.now().isoformat(),
|
||||
"period_days": days,
|
||||
"passed": passed_ids,
|
||||
"failed": failed_ids,
|
||||
"results": [{k: v for k, v in r.items() if k != "spec"} for r in results],
|
||||
}
|
||||
(out_dir / "united_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||||
|
||||
try:
|
||||
from cluster_audit.sync_united import main as sync_united
|
||||
sync_united()
|
||||
log.info("main.mq5 + UnitedEA_Optimized.set updated")
|
||||
except Exception as ex:
|
||||
log.warn(f"sync_united skipped: {ex}")
|
||||
|
||||
log.banner(f"PASSED {len(passed_ids)} / FAILED {len(failed_ids)}")
|
||||
for r in results:
|
||||
if not r.get("passed"):
|
||||
continue
|
||||
o = r["optimized"]
|
||||
log.info(
|
||||
f" {r['id']:28} net=${o['net_profit']:9.0f} "
|
||||
f"trades={o['total_trades']:5} pf={o['profit_factor']:.2f}"
|
||||
)
|
||||
for fid in failed_ids:
|
||||
log.warn(f" NEEDS FIX: {fid}")
|
||||
finally:
|
||||
mt5.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""US30 lot sweep — pick lot balancing return vs equity drawdown."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from cluster_audit.united_mt5_manifest import ALL_ENABLE_KEYS, UNITED_MT5_STRATEGIES
|
||||
from cluster_audit.united_mt5_runner import BASE_SET, deploy_united, mt5_context, patch_set, run_backtest
|
||||
|
||||
OUT = Path(__file__).resolve().parent / "reports" / "us30_lot_dd"
|
||||
LOTS = [0.03, 0.04, 0.05, 0.06, 0.07, 0.08]
|
||||
MAX_DD_PCT = 35.0 # reject lots with equity DD above this
|
||||
|
||||
|
||||
def parse_dd_pct(dd: str | None) -> float | None:
|
||||
if not dd:
|
||||
return None
|
||||
m = re.search(r"([\d.]+)\s*%", str(dd).replace(",", ""))
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sm = {s["id"]: s for s in UNITED_MT5_STRATEGIES}
|
||||
spec = sm["RS_US30"]
|
||||
ov_base = {
|
||||
k: False for k in ALL_ENABLE_KEYS
|
||||
}
|
||||
ov_base[spec["enable"]] = True
|
||||
ov_base.update({
|
||||
"ORCH_ReferenceBalance": 3000.0,
|
||||
"ORCH_ScaleLotsByBalance": True,
|
||||
"GAP_Enable": False,
|
||||
"OPT_GuardOptimizationMode": True,
|
||||
"EnableRSIScalpingMU": False,
|
||||
})
|
||||
|
||||
ctx = mt5_context()
|
||||
deploy_united(ctx["data"], ctx["mt5_path"])
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
trials: list[dict] = []
|
||||
best_lot, best_sc, best_row = LOTS[0], -1e18, {}
|
||||
|
||||
for lot in LOTS:
|
||||
ov = {**ov_base, spec["lot"]: lot}
|
||||
tag = str(lot).replace(".", "p")
|
||||
m = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
patch_set(BASE_SET, ov), f"us30_dd_{tag}.set", f"us30_dd_{tag}",
|
||||
)
|
||||
dd_pct = parse_dd_pct(m.get("max_drawdown"))
|
||||
pf = float(m.get("profit_factor") or 0)
|
||||
sharpe = float(m.get("sharpe") or 0)
|
||||
profit = float(m.get("net_profit") or 0)
|
||||
trades = int(m.get("total_trades") or 0)
|
||||
if not m.get("ready") or trades < 20 or pf < 1.0:
|
||||
sc = -1e10
|
||||
elif dd_pct is not None and dd_pct > MAX_DD_PCT:
|
||||
sc = sharpe * 500 + profit / 2000 - dd_pct * 100
|
||||
else:
|
||||
sc = sharpe * 2000 + profit / 500 + pf * 50 - (dd_pct or 0) * 20
|
||||
row = {"lot": lot, "dd_pct": dd_pct, "score": sc, "metrics": m}
|
||||
trials.append(row)
|
||||
print(
|
||||
f"lot={lot} PF={pf} net={profit} sharpe={sharpe} dd={m.get('max_drawdown')} sc={sc:.0f}",
|
||||
flush=True,
|
||||
)
|
||||
if sc > best_sc:
|
||||
best_sc, best_lot, best_row = sc, lot, row
|
||||
|
||||
# Prefer highest lot under DD cap with PF>=1.1
|
||||
under_cap = [t for t in trials if t.get("dd_pct") is not None and t["dd_pct"] <= MAX_DD_PCT
|
||||
and (t["metrics"].get("profit_factor") or 0) >= 1.1]
|
||||
if under_cap:
|
||||
best_lot = max(under_cap, key=lambda t: t["lot"])["lot"]
|
||||
best_row = next(t for t in trials if t["lot"] == best_lot)
|
||||
|
||||
result = {"best_lot": best_lot, "max_dd_cap_pct": MAX_DD_PCT, "best": best_row, "trials": trials}
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
(OUT / "us30_lot_dd.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
|
||||
print(f"BEST lot={best_lot} dd={best_row.get('dd_pct')}% PF={best_row['metrics'].get('profit_factor')}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Quick solo scan for low-margin stocks using NVDA-style RSI params."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
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
|
||||
from cluster_audit.united_mt5_runner import BASE_SET, deploy_united, mt5_context, patch_set, run_backtest
|
||||
import cluster_audit.united_mt5_runner as runner
|
||||
|
||||
SYMBOLS = ("SNAP.NYS", "F.NYS", "SOFI.NAS", "PFE.NYS", "AAL.NAS", "NVDA.NAS", "BAC.NYS", "WBD.NAS")
|
||||
|
||||
NVDA_PARAMS = {
|
||||
"RS_F_Symbol": "",
|
||||
"RS_F_TimeFrame": 15,
|
||||
"RS_F_RSI_Period": 8,
|
||||
"RS_F_RSI_Overbought": 36,
|
||||
"RS_F_RSI_Oversold": 38,
|
||||
"RS_F_RSI_Target_Buy": 90,
|
||||
"RS_F_RSI_Target_Sell": 70,
|
||||
"RS_F_BarsToWait": 5,
|
||||
"LOT_RS_F": 10,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ctx = mt5_context()
|
||||
deploy_united(ctx["data"], ctx["mt5_path"])
|
||||
print(f"{'symbol':14} {'trades':>6} {'PF':>6} {'net':>10}")
|
||||
for sym in SYMBOLS:
|
||||
o = {k: False for k in ALL_ENABLE_KEYS}
|
||||
o["EnableRSIScalpingF"] = True
|
||||
for k in HIGH_MARGIN_STOCK_ENABLES:
|
||||
o[k] = False
|
||||
o["GAP_Enable"] = False
|
||||
o.update(NVDA_PARAMS)
|
||||
o["RS_F_Symbol"] = sym
|
||||
body = patch_set(BASE_SET, o)
|
||||
report = f"scan_{sym.replace('.', '_')}"
|
||||
old = runner.TEST_SYMBOL
|
||||
runner.TEST_SYMBOL = sym
|
||||
m = run_backtest(
|
||||
ctx["data"], ctx["mt5_path"], ctx["login"], ctx["server"],
|
||||
body, f"{report}.set", report,
|
||||
)
|
||||
runner.TEST_SYMBOL = old
|
||||
print(
|
||||
f"{sym:14} {int(m.get('total_trades') or 0):6} "
|
||||
f"{float(m.get('profit_factor') or 0):6.2f} {float(m.get('net_profit') or 0):10.2f}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Strategy scoring — requires ~1 trade per calendar day over the backtest period."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from .backtest_core import BacktestReport
|
||||
|
||||
DEFAULT_TRADES_PER_DAY = 1.0
|
||||
|
||||
|
||||
def period_days(start: date | datetime | str, end: date | datetime | str) -> int:
|
||||
if isinstance(start, str):
|
||||
start = datetime.fromisoformat(start)
|
||||
if isinstance(end, str):
|
||||
end = datetime.fromisoformat(end)
|
||||
if isinstance(start, datetime):
|
||||
start = start.date()
|
||||
if isinstance(end, datetime):
|
||||
end = end.date()
|
||||
return max(1, (end - start).days)
|
||||
|
||||
|
||||
def min_trades_for_period(days: int, trades_per_day: float = DEFAULT_TRADES_PER_DAY) -> int:
|
||||
return max(30, int(days * trades_per_day))
|
||||
|
||||
|
||||
def trades_per_day(report: BacktestReport, days: int) -> float:
|
||||
if report.total_trades == 0 or days <= 0:
|
||||
return 0.0
|
||||
return report.total_trades / days
|
||||
|
||||
|
||||
def winning_months_pct(report: BacktestReport) -> float:
|
||||
if not report.monthly_returns:
|
||||
return 0.0
|
||||
vals = list(report.monthly_returns.values())
|
||||
return 100.0 * sum(1 for v in vals if v > 0) / len(vals)
|
||||
|
||||
|
||||
def score_report(
|
||||
report: BacktestReport,
|
||||
period_days_count: int,
|
||||
trades_per_day_target: float = DEFAULT_TRADES_PER_DAY,
|
||||
) -> float:
|
||||
"""Higher is better. Hard-fails below activity + profit gates."""
|
||||
min_t = min_trades_for_period(period_days_count, trades_per_day_target)
|
||||
t = report.total_trades
|
||||
tpd = trades_per_day(report, period_days_count)
|
||||
|
||||
if t < min_t or tpd < trades_per_day_target:
|
||||
return float("-inf")
|
||||
|
||||
if report.net_profit <= 0 or report.profit_factor < 1.05:
|
||||
return float("-inf")
|
||||
|
||||
win_mo = winning_months_pct(report) / 100.0
|
||||
activity = min(tpd / (trades_per_day_target * 1.5), 1.0)
|
||||
pf = min(report.profit_factor, 4.0) / 4.0
|
||||
wr = min(report.win_rate, 70.0) / 70.0
|
||||
|
||||
return (
|
||||
report.sharpe * 0.25
|
||||
+ (report.net_profit / 2000.0) * 0.18
|
||||
- report.max_drawdown_pct * 0.10
|
||||
+ activity * 0.22
|
||||
+ win_mo * 0.12
|
||||
+ pf * 0.08
|
||||
+ wr * 0.05
|
||||
)
|
||||
|
||||
|
||||
def score_label(
|
||||
score: float,
|
||||
trades: int,
|
||||
period_days_count: int,
|
||||
trades_per_day_target: float = DEFAULT_TRADES_PER_DAY,
|
||||
) -> str:
|
||||
min_t = min_trades_for_period(period_days_count, trades_per_day_target)
|
||||
tpd = trades / period_days_count if period_days_count else 0
|
||||
if trades < min_t:
|
||||
return f"N/A ({trades}<{min_t}, need {trades_per_day_target:.1f}/day)"
|
||||
if tpd < trades_per_day_target:
|
||||
return f"N/A ({tpd:.2f}/day < {trades_per_day_target:.1f}/day)"
|
||||
if score == float("-inf"):
|
||||
return "N/A (fails profit gates)"
|
||||
return f"{score:.2f}"
|
||||
|
||||
|
||||
def acceptance(
|
||||
report: BacktestReport,
|
||||
period_days_count: int,
|
||||
trades_per_day_target: float = DEFAULT_TRADES_PER_DAY,
|
||||
) -> tuple[bool, list[str]]:
|
||||
min_t = min_trades_for_period(period_days_count, trades_per_day_target)
|
||||
tpd = trades_per_day(report, period_days_count)
|
||||
issues: list[str] = []
|
||||
|
||||
if report.total_trades < min_t:
|
||||
issues.append(f"trades={report.total_trades} need >={min_t} ({trades_per_day_target:.1f}/day x {period_days_count}d)")
|
||||
if tpd < trades_per_day_target:
|
||||
issues.append(f"trades/day={tpd:.2f} need >={trades_per_day_target:.1f}")
|
||||
if report.net_profit <= 0:
|
||||
issues.append(f"net=${report.net_profit:.0f} not positive")
|
||||
if report.profit_factor < 1.15:
|
||||
issues.append(f"pf={report.profit_factor:.2f} need >=1.15")
|
||||
if report.sharpe < 0.3:
|
||||
issues.append(f"sharpe={report.sharpe:.2f} need >=0.30")
|
||||
if report.max_drawdown_pct > 25:
|
||||
issues.append(f"dd={report.max_drawdown_pct:.1f}% too high")
|
||||
|
||||
win_mo = winning_months_pct(report)
|
||||
if win_mo < 45:
|
||||
issues.append(f"winning_months={win_mo:.0f}% need >=45%")
|
||||
|
||||
return len(issues) == 0, issues
|
||||
|
||||
|
||||
def format_quality_line(report: BacktestReport, period_days_count: int) -> str:
|
||||
tpd = trades_per_day(report, period_days_count)
|
||||
return (
|
||||
f"net=${report.net_profit:.0f} sharpe={report.sharpe:.2f} "
|
||||
f"trades={report.total_trades} ({tpd:.2f}/day) pf={report.profit_factor:.2f} "
|
||||
f"wr={report.win_rate:.0f}% win_mo={winning_months_pct(report):.0f}% "
|
||||
f"dd={report.max_drawdown_pct:.1f}%"
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""All SuperEA robot instances with defaults and optimization ranges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
|
||||
TF = {
|
||||
"M10": mt5.TIMEFRAME_M10,
|
||||
"M15": mt5.TIMEFRAME_M15,
|
||||
"M20": mt5.TIMEFRAME_M20,
|
||||
"M30": mt5.TIMEFRAME_M30,
|
||||
"H1": mt5.TIMEFRAME_H1,
|
||||
"H4": mt5.TIMEFRAME_H4,
|
||||
}
|
||||
|
||||
STRATEGIES: list[dict] = [
|
||||
{"id": "darvas_xau", "engine": "darvas", "symbol": "XAUUSD", "tf": "M15", "lot": 0.07,
|
||||
"defaults": {"box_period": 24, "box_deviation": 80000, "ma_period": 60,
|
||||
"trend_threshold": 1.2, "volume_threshold": 0,
|
||||
"stop_loss_pts": 450, "take_profit_pts": 650},
|
||||
"opt": {"box_period": (12, 48, 4), "box_deviation": (40000, 150000, 5000),
|
||||
"trend_threshold": (0.3, 5.0, 0.3), "stop_loss_pts": (250, 900, 50),
|
||||
"take_profit_pts": (350, 1200, 50), "ma_period": (30, 120, 15)}},
|
||||
{"id": "ema_slope_unit", "engine": "ema_slope", "symbol": "XAUUSD", "tf": "H1", "lot": 0.07,
|
||||
"defaults": {"ema_period": 85, "price_threshold_pips": 350, "slope_threshold_pips": 22.5,
|
||||
"monitor_timeout_sec": 340, "max_trades_per_crossover": 12, "use_trailing_stop": False,
|
||||
"trailing_stop_pips": 74, "max_loss_atr": 1.8, "use_bar_data": True,
|
||||
"close_unprofitable_trades": True, "profit_check_bars": 36,
|
||||
"use_weekly_adx_filter": True, "weekly_adx_period": 28,
|
||||
"weekly_adx_min": 25, "weekly_adx_bar_shift": 8, "weekly_adx_use_direction": True},
|
||||
"opt": {"ema_period": (50, 120, 5), "price_threshold_pips": (150, 500, 50),
|
||||
"slope_threshold_pips": (10, 50, 5), "max_loss_atr": (1.2, 3.0, 0.3),
|
||||
"profit_check_bars": (18, 60, 6), "max_trades_per_crossover": (3, 20, 3)}},
|
||||
{"id": "ema_slope_trail", "engine": "ema_slope", "symbol": "XAUUSD", "tf": "H1", "lot": 0.07,
|
||||
"defaults": {"ema_period": 50, "price_threshold_pips": 700, "slope_threshold_pips": 25,
|
||||
"monitor_timeout_sec": 340, "max_trades_per_crossover": 3, "use_trailing_stop": True,
|
||||
"trailing_stop_pips": 370, "max_loss_atr": 2.0, "use_bar_data": True,
|
||||
"close_unprofitable_trades": True, "profit_check_bars": 11,
|
||||
"use_weekly_adx_filter": True, "weekly_adx_period": 15,
|
||||
"weekly_adx_min": 40, "weekly_adx_bar_shift": 2, "weekly_adx_use_direction": True},
|
||||
"opt": {"ema_period": (30, 90, 5), "trailing_stop_pips": (150, 450, 50),
|
||||
"max_loss_atr": (1.2, 3.5, 0.3), "weekly_adx_min": (25, 50, 5)}},
|
||||
{"id": "mean_rev_btc", "engine": "mean_reversion", "symbol": "BTCUSD", "tf": "M15", "lot": 0.01,
|
||||
"defaults": {"ema_period": 80, "min_ema_distance_pts": 300, "rsi_period": 14,
|
||||
"rsi_oversold": 40, "rsi_overbought": 70, "adx_period": 14,
|
||||
"adx_max_for_entry": 30, "adx_escape": 40, "use_rsi_cross": True,
|
||||
"use_hard_sltp": False, "sl_points": 1300, "tp_points": 13400},
|
||||
"opt": {"rsi_oversold": (30, 50, 5), "rsi_overbought": (60, 80, 5),
|
||||
"min_ema_distance_pts": (100, 1500, 100), "adx_max_for_entry": (22, 40, 3),
|
||||
"ema_period": (40, 120, 20)}},
|
||||
{"id": "rsi_cross_xau", "engine": "rsi_crossover", "symbol": "XAUUSD", "tf": "H1", "lot": 0.01,
|
||||
"defaults": {"rsi_period": 19, "overbought_level": 93, "oversold_level": 22, "ema_period": 140,
|
||||
"ema_slope_threshold": 105, "ema_distance_threshold": 165, "exit_buy_rsi": 86,
|
||||
"exit_sell_rsi": 10, "trailing_stop_pts": 295, "cooldown_seconds": 209,
|
||||
"tuesday": True, "wednesday": True, "thursday": True,
|
||||
"trading_hour_one_begin": 0, "trading_hour_one_end": 22,
|
||||
"trading_hour_two_begin": 6, "trading_hour_two_end": 19},
|
||||
"opt": {"overbought_level": (65, 95, 5), "oversold_level": (10, 45, 5),
|
||||
"ema_slope_threshold": (20, 200, 20), "ema_distance_threshold": (40, 300, 20)}},
|
||||
{"id": "rsi_asian_eur", "engine": "rsi_asian", "symbol": "EURUSD", "tf": "M15", "lot": 0.1,
|
||||
"defaults": {"rsi_period": 28, "overbought_level": 60, "oversold_level": 8,
|
||||
"asian_session_start": 0, "asian_session_end": 8, "use_rsi_exit": True, "rsi_exit_level": 55},
|
||||
"opt": {"overbought_level": (50, 80, 5), "oversold_level": (5, 40, 5)}},
|
||||
{"id": "rsi_asian_aud", "engine": "rsi_asian", "symbol": "AUDUSD", "tf": "M15", "lot": 0.1,
|
||||
"defaults": {"rsi_period": 28, "overbought_level": 68, "oversold_level": 30,
|
||||
"asian_session_start": 0, "asian_session_end": 8, "use_rsi_exit": True, "rsi_exit_level": 55},
|
||||
"opt": {"overbought_level": (55, 80, 5), "oversold_level": (15, 45, 5)}},
|
||||
{"id": "rsi_asian_gbp", "engine": "rsi_asian", "symbol": "GBPUSD", "tf": "M15", "lot": 0.1,
|
||||
"defaults": {"rsi_period": 28, "overbought_level": 80, "oversold_level": 37,
|
||||
"asian_session_start": 0, "asian_session_end": 8, "use_rsi_exit": True, "rsi_exit_level": 55},
|
||||
"opt": {"overbought_level": (60, 85, 5), "oversold_level": (20, 50, 5)}},
|
||||
{"id": "rsi_secret_xau", "engine": "rsi_secret", "symbol": "XAUUSD", "tf": "M30", "lot": 0.01,
|
||||
"defaults": {"rsi_period": 16, "rsi_overbought": 72.5, "rsi_oversold": 32.5,
|
||||
"stop_loss_atr": 2.75, "take_profit_atr": 5.0, "min_bars_between_trades": 7},
|
||||
"opt": {"rsi_overbought": (60, 80, 2), "rsi_oversold": (25, 45, 2), "stop_loss_atr": (1.5, 4, 0.5)}},
|
||||
]
|
||||
|
||||
_RSI_SCALPS = [
|
||||
("rsi_scalp_appl_unit", "AAPL", "M10", 25.0, {"rsi_period": 14, "rsi_overbought": 80, "rsi_oversold": 78,
|
||||
"rsi_target_buy": 94, "rsi_target_sell": 44, "bars_to_wait": 7, "use_trailing": False}),
|
||||
("rsi_scalp_appl_trail", "AAPL", "H1", 25.0, {"rsi_period": 8, "rsi_overbought": 62, "rsi_oversold": 32,
|
||||
"rsi_target_buy": 67, "rsi_target_sell": 2, "bars_to_wait": 5, "use_trailing": True,
|
||||
"trail_distance_pts": 50, "trail_activation_pts": 39}),
|
||||
("rsi_scalp_adbe_trail", "ADBE", "H1", 5.0, {"rsi_period": 15, "rsi_overbought": 16, "rsi_oversold": 42,
|
||||
"rsi_target_buy": 67, "rsi_target_sell": 62, "bars_to_wait": 8, "use_trailing": True,
|
||||
"trail_distance_pts": 425, "trail_activation_pts": 18.5}),
|
||||
("rsi_scalp_btc_unit", "BTCUSD", "H1", 0.1, {"rsi_period": 14, "rsi_overbought": 90, "rsi_oversold": 73,
|
||||
"rsi_target_buy": 88, "rsi_target_sell": 48, "bars_to_wait": 6, "use_trailing": False}),
|
||||
("rsi_scalp_btc_trail", "BTCUSD", "H1", 0.1, {"rsi_period": 14, "rsi_overbought": 90, "rsi_oversold": 73,
|
||||
"rsi_target_buy": 88, "rsi_target_sell": 48, "bars_to_wait": 6, "use_trailing": True,
|
||||
"trail_distance_pts": 120, "trail_activation_pts": 0}),
|
||||
("rsi_scalp_mu", "MU", "H1", 25.0, {"rsi_period": 14, "rsi_overbought": 32, "rsi_oversold": 86,
|
||||
"rsi_target_buy": 100, "rsi_target_sell": 24, "bars_to_wait": 34, "use_trailing": False}),
|
||||
("rsi_scalp_nvda_unit", "NVDA", "H1", 25.0, {"rsi_period": 14, "rsi_overbought": 6, "rsi_oversold": 66,
|
||||
"rsi_target_buy": 98, "rsi_target_sell": 52, "bars_to_wait": 12, "use_trailing": False}),
|
||||
("rsi_scalp_nvda_trail", "NVDA", "M15", 50.0, {"rsi_period": 8, "rsi_overbought": 36, "rsi_oversold": 38,
|
||||
"rsi_target_buy": 90, "rsi_target_sell": 70, "bars_to_wait": 5, "use_trailing": True,
|
||||
"trail_distance_pts": 375, "trail_activation_pts": 75}),
|
||||
("rsi_scalp_nvda_trail_v2", "NVDA", "M15", 50.0, {"rsi_period": 8, "rsi_overbought": 36, "rsi_oversold": 38,
|
||||
"rsi_target_buy": 90, "rsi_target_sell": 70, "bars_to_wait": 5, "use_trailing": True,
|
||||
"trail_distance_pts": 375, "trail_activation_pts": 75}),
|
||||
("rsi_scalp_tsla_unit", "TSLA", "H1", 25.0, {"rsi_period": 14, "rsi_overbought": 32, "rsi_oversold": 86,
|
||||
"rsi_target_buy": 100, "rsi_target_sell": 24, "bars_to_wait": 34, "use_trailing": False}),
|
||||
("rsi_scalp_tsla_trail", "TSLA", "H1", 5.0, {"rsi_period": 14, "rsi_overbought": 54, "rsi_oversold": 73,
|
||||
"rsi_target_buy": 87, "rsi_target_sell": 33, "bars_to_wait": 1, "use_trailing": True,
|
||||
"trail_distance_pts": 900, "trail_activation_pts": 950}),
|
||||
("rsi_scalp_xau_trail", "XAUUSD", "H1", 0.1, {"rsi_period": 14, "rsi_overbought": 71, "rsi_oversold": 57,
|
||||
"rsi_target_buy": 80, "rsi_target_sell": 57, "bars_to_wait": 1, "use_trailing": True,
|
||||
"trail_distance_pts": 71, "trail_activation_pts": 41}),
|
||||
]
|
||||
|
||||
for sid, sym, tf, lot, defaults in _RSI_SCALPS:
|
||||
STRATEGIES.append({
|
||||
"id": sid,
|
||||
"engine": "rsi_scalp",
|
||||
"symbol": sym,
|
||||
"tf": tf,
|
||||
"lot": lot,
|
||||
"defaults": {**defaults, "tf": tf},
|
||||
"opt": {
|
||||
"rsi_period": (6, 21, 1),
|
||||
"rsi_overbought": (50, 90, 3),
|
||||
"rsi_oversold": (10, 70, 3),
|
||||
"rsi_target_buy": (60, 95, 3),
|
||||
"rsi_target_sell": (5, 70, 3),
|
||||
"bars_to_wait": (1, 8, 1),
|
||||
"trail_distance_pts": (20, 400, 20),
|
||||
},
|
||||
})
|
||||
|
||||
PERIODS = {
|
||||
"2021-2026": ("2021-01-01", "2026-06-01"),
|
||||
"2024-2026": ("2024-01-01", "2026-06-01"),
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Build cluster-latest SuperEA audit params from sequential JSON reports.
|
||||
|
||||
Usage:
|
||||
python -m cluster_audit.sync_cluster
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from cluster_audit.scoring import DEFAULT_TRADES_PER_DAY, acceptance, period_days, trades_per_day
|
||||
from cluster_audit.strategy_registry import PERIODS, STRATEGIES
|
||||
|
||||
REPORTS = Path(__file__).parent / "reports" / "sequential"
|
||||
OUT_MQH = Path(__file__).resolve().parents[3] / "frontline" / "cluster-latest" / "SuperEA_AuditParams.mqh"
|
||||
OUT_JSON = Path(__file__).parent / "reports" / "cluster_manifest.json"
|
||||
|
||||
RSI_SCALP_IDS = [
|
||||
"rsi_scalp_appl_unit", "rsi_scalp_appl_trail", "rsi_scalp_adbe_trail",
|
||||
"rsi_scalp_btc_unit", "rsi_scalp_btc_trail", "rsi_scalp_mu",
|
||||
"rsi_scalp_nvda_unit", "rsi_scalp_nvda_trail", "rsi_scalp_nvda_trail_v2",
|
||||
"rsi_scalp_tsla_unit", "rsi_scalp_tsla_trail", "rsi_scalp_xau_trail",
|
||||
]
|
||||
RSI_INDEX = {sid: i for i, sid in enumerate(RSI_SCALP_IDS)}
|
||||
|
||||
MAGIC_MAP = {s["id"]: 401000 + i for i, s in enumerate(STRATEGIES, 1)}
|
||||
|
||||
|
||||
def load_report(sid: str) -> dict | None:
|
||||
p = REPORTS / f"{sid}_2021-2026.json"
|
||||
if not p.exists():
|
||||
return None
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def spec_defaults(sid: str) -> dict:
|
||||
for s in STRATEGIES:
|
||||
if s["id"] == sid:
|
||||
return dict(s["defaults"])
|
||||
return {}
|
||||
|
||||
|
||||
def evaluate_report(r: dict, days: int) -> tuple[bool, list[str]]:
|
||||
from cluster_audit.backtest_core import BacktestReport
|
||||
|
||||
o = r.get("optimized", {})
|
||||
rep = BacktestReport(
|
||||
strategy_id=r["id"],
|
||||
symbol=r.get("symbol", ""),
|
||||
timeframe=r.get("timeframe", "H1"),
|
||||
period_label="2021-2026",
|
||||
net_profit=float(o.get("net_profit", 0)),
|
||||
total_trades=int(o.get("total_trades", 0)),
|
||||
win_rate=float(o.get("win_rate", 0)),
|
||||
profit_factor=float(o.get("profit_factor", 0)),
|
||||
sharpe=float(o.get("sharpe", 0)),
|
||||
max_drawdown_pct=float(o.get("max_drawdown_pct", 0)),
|
||||
avg_win=float(o.get("avg_win", 0)),
|
||||
avg_loss=float(o.get("avg_loss", 0)),
|
||||
worst_trades=o.get("worst_trades", []),
|
||||
losing_trades=o.get("losing_trades", []),
|
||||
exit_reason_breakdown=o.get("exit_reason_breakdown", {}),
|
||||
monthly_returns=o.get("monthly_returns", {}),
|
||||
params=o.get("params", {}),
|
||||
)
|
||||
if r.get("passed") is True:
|
||||
ok, issues = acceptance(rep, days, DEFAULT_TRADES_PER_DAY)
|
||||
if ok:
|
||||
return True, []
|
||||
return acceptance(rep, days, DEFAULT_TRADES_PER_DAY)
|
||||
|
||||
|
||||
def _lit_bool(v) -> str:
|
||||
return "true" if v else "false"
|
||||
|
||||
|
||||
def emit_darvas(params: dict, ok: bool) -> list[str]:
|
||||
d = {**spec_defaults("darvas_xau"), **params}
|
||||
return [
|
||||
"static void SE_AuditDarvas(DarvasBoxConfig &c)",
|
||||
"{",
|
||||
f" c.box_period = {int(d['box_period'])};",
|
||||
f" c.box_deviation = {float(d['box_deviation'])};",
|
||||
f" c.ma_period = {int(d['ma_period'])};",
|
||||
f" c.trend_threshold = {float(d['trend_threshold'])};",
|
||||
f" c.stop_loss_pts = {float(d['stop_loss_pts'])};",
|
||||
f" c.take_profit_pts = {float(d['take_profit_pts'])};",
|
||||
" c.box_timeframe = PERIOD_M15;",
|
||||
" c.trend_timeframe = PERIOD_M15;",
|
||||
" c.use_close_breakout = true;",
|
||||
" c.require_volume_ma = false;",
|
||||
"}",
|
||||
f"static bool SE_AuditDarvasEnabled() {{ return {_lit_bool(ok)}; }}",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def emit_ema_slope(fn: str, params: dict, ok: bool) -> list[str]:
|
||||
d = params
|
||||
lines = [f"static void SE_Audit{fn}(EmaSlopeConfig &c)", "{"]
|
||||
for key, cast in [
|
||||
("ema_period", int), ("price_threshold_pips", float), ("slope_threshold_pips", float),
|
||||
("monitor_timeout_sec", int), ("trailing_stop_pips", float),
|
||||
("max_trades_per_crossover", int), ("profit_check_bars", int),
|
||||
("weekly_adx_period", int), ("weekly_adx_min", float), ("weekly_adx_bar_shift", int),
|
||||
]:
|
||||
if key in d:
|
||||
lines.append(f" c.{key} = {cast(d[key])};")
|
||||
if "use_trailing_stop" in d:
|
||||
lines.append(f" c.use_trailing_stop = {_lit_bool(d['use_trailing_stop'])};")
|
||||
lines += ["}", f"static bool SE_Audit{fn}Enabled() {{ return {_lit_bool(ok)}; }}", ""]
|
||||
return lines
|
||||
|
||||
|
||||
def emit_mean_rev(params: dict, ok: bool) -> list[str]:
|
||||
d = {**spec_defaults("mean_rev_btc"), **params}
|
||||
return [
|
||||
"static void SE_AuditMeanRev(MeanReversionConfig &c)",
|
||||
"{",
|
||||
f" c.ema_period = {int(d.get('ema_period', 250))};",
|
||||
f" c.min_ema_distance_pts = {float(d.get('min_ema_distance_pts', 3650))};",
|
||||
f" c.rsi_period = {int(d.get('rsi_period', 28))};",
|
||||
f" c.rsi_oversold = {float(d.get('rsi_oversold', 40))};",
|
||||
f" c.rsi_overbought = {float(d.get('rsi_overbought', 83))};",
|
||||
f" c.adx_period = {int(d.get('adx_period', 14))};",
|
||||
f" c.adx_max_for_entry = {float(d.get('adx_max_for_entry', 17))};",
|
||||
f" c.adx_escape = {float(d.get('adx_escape', 34))};",
|
||||
f" c.use_rsi_cross = {_lit_bool(d.get('use_rsi_cross', True))};",
|
||||
f" c.use_hard_sltp = {_lit_bool(d.get('use_hard_sltp', False))};",
|
||||
f" c.sl_points = {float(d.get('sl_points', 1300))};",
|
||||
f" c.tp_points = {float(d.get('tp_points', 13400))};",
|
||||
"}",
|
||||
f"static bool SE_AuditMeanRevEnabled() {{ return {_lit_bool(ok)}; }}",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def emit_rsi_cross(params: dict, ok: bool) -> list[str]:
|
||||
d = {**spec_defaults("rsi_cross_xau"), **params}
|
||||
return [
|
||||
"static void SE_AuditRsiCross(RsiCrossOverConfig &c)",
|
||||
"{",
|
||||
f" c.rsi_period = {int(d.get('rsi_period', 19))};",
|
||||
f" c.overbought_level = {float(d.get('overbought_level', 93))};",
|
||||
f" c.oversold_level = {float(d.get('oversold_level', 22))};",
|
||||
f" c.ema_period = {int(d.get('ema_period', 140))};",
|
||||
f" c.ema_slope_threshold = {float(d.get('ema_slope_threshold', 105))};",
|
||||
f" c.ema_distance_threshold = {float(d.get('ema_distance_threshold', 165))};",
|
||||
f" c.exit_buy_rsi = {float(d.get('exit_buy_rsi', 86))};",
|
||||
f" c.exit_sell_rsi = {float(d.get('exit_sell_rsi', 10))};",
|
||||
f" c.trailing_stop_pts = {float(d.get('trailing_stop_pts', 295))};",
|
||||
f" c.cooldown_seconds = {int(d.get('cooldown_seconds', 209))};",
|
||||
"}",
|
||||
f"static bool SE_AuditRsiCrossEnabled() {{ return {_lit_bool(ok)}; }}",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def emit_rsi_asian(fn: str, params: dict, ok: bool) -> list[str]:
|
||||
d = params
|
||||
return [
|
||||
f"static void SE_Audit{fn}(RsiAsianConfig &c)",
|
||||
"{",
|
||||
f" c.rsi_period = {int(d.get('rsi_period', 28))};",
|
||||
f" c.overbought_level = {float(d.get('overbought_level', 60))};",
|
||||
f" c.oversold_level = {float(d.get('oversold_level', 8))};",
|
||||
f" c.asian_session_start = {int(d.get('asian_session_start', 0))};",
|
||||
f" c.asian_session_end = {int(d.get('asian_session_end', 8))};",
|
||||
f" c.use_rsi_exit = {_lit_bool(d.get('use_rsi_exit', True))};",
|
||||
f" c.rsi_exit_level = {float(d.get('rsi_exit_level', 55))};",
|
||||
"}",
|
||||
f"static bool SE_Audit{fn}Enabled() {{ return {_lit_bool(ok)}; }}",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def emit_rsi_secret(params: dict, ok: bool) -> list[str]:
|
||||
d = {**spec_defaults("rsi_secret_xau"), **params}
|
||||
return [
|
||||
"static void SE_AuditRsiSecret(RsiSecretSauceConfig &c)",
|
||||
"{",
|
||||
f" c.rsi_period = {int(d.get('rsi_period', 16))};",
|
||||
f" c.rsi_overbought = {float(d.get('rsi_overbought', 72.5))};",
|
||||
f" c.rsi_oversold = {float(d.get('rsi_oversold', 32.5))};",
|
||||
f" c.stop_loss_atr = {float(d.get('stop_loss_atr', 2.75))};",
|
||||
f" c.take_profit_atr = {float(d.get('take_profit_atr', 5.0))};",
|
||||
f" c.min_bars_between_trades = {int(d.get('min_bars_between_trades', 7))};",
|
||||
"}",
|
||||
f"static bool SE_AuditRsiSecretEnabled() {{ return {_lit_bool(ok)}; }}",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def emit_rsi_scalp(idx: int, sid: str, params: dict, ok: bool) -> list[str]:
|
||||
d = {**spec_defaults(sid), **params}
|
||||
return [
|
||||
f"static void SE_AuditRsi{idx}(RsiScalpConfig &c)",
|
||||
"{",
|
||||
f" c.rsi_period = {int(d.get('rsi_period', 14))};",
|
||||
f" c.rsi_overbought = {float(d.get('rsi_overbought', 70))};",
|
||||
f" c.rsi_oversold = {float(d.get('rsi_oversold', 30))};",
|
||||
f" c.rsi_target_buy = {float(d.get('rsi_target_buy', 80))};",
|
||||
f" c.rsi_target_sell = {float(d.get('rsi_target_sell', 50))};",
|
||||
f" c.bars_to_wait = {int(d.get('bars_to_wait', 5))};",
|
||||
f" c.use_trailing = {_lit_bool(d.get('use_trailing', False))};",
|
||||
f" c.trail_distance_pts = {float(d.get('trail_distance_pts', 0))};",
|
||||
f" c.trail_activation_pts = {float(d.get('trail_activation_pts', 0))};",
|
||||
"}",
|
||||
f"static bool SE_AuditRsi{idx}Enabled() {{ return {_lit_bool(ok)}; }}",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def stub_enabled(name: str, ok: bool = False) -> list[str]:
|
||||
return [f"static bool SE_Audit{name}Enabled() {{ return {_lit_bool(ok)}; }}", ""]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
start, end = PERIODS["2021-2026"]
|
||||
days = period_days(start, end)
|
||||
manifest: dict = {
|
||||
"generated": datetime.now().isoformat(),
|
||||
"period_days": days,
|
||||
"trades_per_day_target": DEFAULT_TRADES_PER_DAY,
|
||||
"strategies": {},
|
||||
}
|
||||
|
||||
reports: dict[str, dict] = {}
|
||||
status: dict[str, bool] = {}
|
||||
params_map: dict[str, dict] = {}
|
||||
|
||||
for spec in STRATEGIES:
|
||||
sid = spec["id"]
|
||||
r = load_report(sid)
|
||||
if not r:
|
||||
manifest["strategies"][sid] = {"status": "no_report", "passed": False}
|
||||
status[sid] = False
|
||||
params_map[sid] = spec_defaults(sid)
|
||||
continue
|
||||
reports[sid] = r
|
||||
params = r.get("optimized_params") or r.get("optimized", {}).get("params", spec_defaults(sid))
|
||||
params_map[sid] = params
|
||||
ok, issues = evaluate_report(r, days)
|
||||
o = r.get("optimized", {})
|
||||
tpd = trades_per_day(
|
||||
type("R", (), {"total_trades": int(o.get("total_trades", 0))})(),
|
||||
days,
|
||||
)
|
||||
status[sid] = ok
|
||||
manifest["strategies"][sid] = {
|
||||
"passed": ok,
|
||||
"magic": MAGIC_MAP.get(sid),
|
||||
"trades": o.get("total_trades"),
|
||||
"trades_per_day": round(tpd, 3),
|
||||
"net_profit": o.get("net_profit"),
|
||||
"sharpe": o.get("sharpe"),
|
||||
"profit_factor": o.get("profit_factor"),
|
||||
"issues": issues,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
lines = [
|
||||
"//+------------------------------------------------------------------+",
|
||||
"//| SuperEA_AuditParams.mqh - optimized params from cluster audit |",
|
||||
f"//| Generated: {datetime.now().isoformat()}",
|
||||
"//+------------------------------------------------------------------+",
|
||||
"#ifndef SUPER_EA_AUDIT_PARAMS_MQH",
|
||||
"#define SUPER_EA_AUDIT_PARAMS_MQH",
|
||||
"",
|
||||
]
|
||||
|
||||
lines += [f"// darvas_xau: {'PASS' if status.get('darvas_xau') else 'DISABLED'}"]
|
||||
lines += emit_darvas(params_map.get("darvas_xau", {}), status.get("darvas_xau", False))
|
||||
|
||||
lines += [f"// ema_slope_unit: {'PASS' if status.get('ema_slope_unit') else 'DISABLED'}"]
|
||||
lines += emit_ema_slope("EmaUnit", params_map.get("ema_slope_unit", {}), status.get("ema_slope_unit", False))
|
||||
|
||||
lines += [f"// ema_slope_trail: {'PASS' if status.get('ema_slope_trail') else 'DISABLED'}"]
|
||||
lines += emit_ema_slope("EmaTrail", params_map.get("ema_slope_trail", {}), status.get("ema_slope_trail", False))
|
||||
|
||||
lines += [f"// mean_rev_btc: {'PASS' if status.get('mean_rev_btc') else 'DISABLED'}"]
|
||||
lines += emit_mean_rev(params_map.get("mean_rev_btc", {}), status.get("mean_rev_btc", False))
|
||||
|
||||
lines += [f"// rsi_cross_xau: {'PASS' if status.get('rsi_cross_xau') else 'DISABLED'}"]
|
||||
lines += emit_rsi_cross(params_map.get("rsi_cross_xau", {}), status.get("rsi_cross_xau", False))
|
||||
|
||||
for sid, fn in [
|
||||
("rsi_asian_eur", "RsiAsianEur"),
|
||||
("rsi_asian_aud", "RsiAsianAud"),
|
||||
("rsi_asian_gbp", "RsiAsianGbp"),
|
||||
]:
|
||||
lines += [f"// {sid}: {'PASS' if status.get(sid) else 'DISABLED'}"]
|
||||
lines += emit_rsi_asian(fn, params_map.get(sid, {}), status.get(sid, False))
|
||||
|
||||
lines += [f"// rsi_secret_xau: {'PASS' if status.get('rsi_secret_xau') else 'DISABLED'}"]
|
||||
lines += emit_rsi_secret(params_map.get("rsi_secret_xau", {}), status.get("rsi_secret_xau", False))
|
||||
|
||||
for sid in RSI_SCALP_IDS:
|
||||
idx = RSI_INDEX[sid]
|
||||
lines += [f"// {sid}: {'PASS' if status.get(sid) else 'DISABLED'}"]
|
||||
lines += emit_rsi_scalp(idx, sid, params_map.get(sid, {}), status.get(sid, False))
|
||||
|
||||
lines += ["#endif", ""]
|
||||
OUT_MQH.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT_MQH.write_text("\n".join(lines), encoding="utf-8")
|
||||
OUT_JSON.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
passed = [k for k, v in status.items() if v]
|
||||
print(f"Wrote {OUT_MQH}")
|
||||
print(f"Wrote {OUT_JSON}")
|
||||
print(f"Passed {len(passed)}/{len(STRATEGIES)}: {', '.join(passed) if passed else '(none)'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Sync United EA audit results into main.mq5 defaults and generate .set file.
|
||||
|
||||
Usage:
|
||||
python -m cluster_audit.sync_united
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from cluster_audit.scoring import DEFAULT_TRADES_PER_DAY, acceptance, period_days, trades_per_day
|
||||
from cluster_audit.united_registry import PERIODS, UNITED_STRATEGIES
|
||||
|
||||
REPORTS = Path(__file__).parent / "reports" / "united_sequential"
|
||||
MAIN_MQ5 = Path(__file__).resolve().parents[3] / "frontline" / "cluster-latest" / "main.mq5"
|
||||
OUT_SET = Path(__file__).resolve().parents[3] / "frontline" / "cluster-latest" / "UnitedEA_Optimized.set"
|
||||
OUT_JSON = REPORTS / "united_manifest.json"
|
||||
|
||||
# main.mq5 input name -> (strategy_id, param_key in optimized JSON)
|
||||
PARAM_PATCHES: dict[str, tuple[str, str]] = {
|
||||
"DB_BoxPeriod": ("united_darvas", "box_period"),
|
||||
"DB_BoxDeviation": ("united_darvas", "box_deviation"),
|
||||
"DB_StopLoss": ("united_darvas", "stop_loss_pts"),
|
||||
"DB_TakeProfit": ("united_darvas", "take_profit_pts"),
|
||||
"DB_MA_Period": ("united_darvas", "ma_period"),
|
||||
"DB_TrendThreshold": ("united_darvas", "trend_threshold"),
|
||||
"RC_overboughtLevel": ("united_rsi_cross", "overbought_level"),
|
||||
"RC_oversoldLevel": ("united_rsi_cross", "oversold_level"),
|
||||
"RC_emaSlopeThreshold": ("united_rsi_cross", "ema_slope_threshold"),
|
||||
"RC_emaDistanceThreshold": ("united_rsi_cross", "ema_distance_threshold"),
|
||||
"RS_APPL_RSI_Period": ("united_rsi_scalp_appl", "rsi_period"),
|
||||
"RS_APPL_RSI_Overbought": ("united_rsi_scalp_appl", "rsi_overbought"),
|
||||
"RS_APPL_RSI_Oversold": ("united_rsi_scalp_appl", "rsi_oversold"),
|
||||
"RS_APPL_RSI_Target_Buy": ("united_rsi_scalp_appl", "rsi_target_buy"),
|
||||
"RS_APPL_RSI_Target_Sell": ("united_rsi_scalp_appl", "rsi_target_sell"),
|
||||
"RS_APPL_BarsToWait": ("united_rsi_scalp_appl", "bars_to_wait"),
|
||||
"RS_APPL_TrailDistancePoints": ("united_rsi_scalp_appl", "trail_distance_pts"),
|
||||
"RS_APPL_TrailActivationPoints": ("united_rsi_scalp_appl", "trail_activation_pts"),
|
||||
"RS_BTCUSD_RSI_Period": ("united_rsi_scalp_btc", "rsi_period"),
|
||||
"RS_BTCUSD_TrailDistancePoints": ("united_rsi_scalp_btc", "trail_distance_pts"),
|
||||
"RS_XAUUSD_RSI_Period": ("united_rsi_scalp_xau", "rsi_period"),
|
||||
"RS_XAUUSD_TrailDistancePoints": ("united_rsi_scalp_xau", "trail_distance_pts"),
|
||||
"RRA_EURUSD_OverboughtLevel": ("united_rsi_asian_eur", "overbought_level"),
|
||||
"RRA_EURUSD_OversoldLevel": ("united_rsi_asian_eur", "oversold_level"),
|
||||
"RSS_RSIOverbought": ("united_rsi_secret", "rsi_overbought"),
|
||||
"RSS_RSIOversold": ("united_rsi_secret", "rsi_oversold"),
|
||||
"UB_MinRangePoints": ("united_usdjpy", "min_range_pts"),
|
||||
"UB_OrderBufferPoints": ("united_usdjpy", "order_buffer_pts"),
|
||||
}
|
||||
|
||||
ENABLE_PATCHES: dict[str, str] = {s["enable_key"]: s["id"] for s in UNITED_STRATEGIES}
|
||||
|
||||
|
||||
def load_report(sid: str) -> dict | None:
|
||||
p = REPORTS / f"{sid}_2021-2026.json"
|
||||
if not p.exists():
|
||||
# fallback cluster audit darvas
|
||||
alt = Path(__file__).parent / "reports" / "sequential" / "darvas_xau_2021-2026.json"
|
||||
if sid == "united_darvas" and alt.exists():
|
||||
r = json.loads(alt.read_text(encoding="utf-8"))
|
||||
r["id"] = sid
|
||||
return r
|
||||
return None
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def evaluate(r: dict, days: int) -> tuple[bool, list[str]]:
|
||||
from cluster_audit.backtest_core import BacktestReport
|
||||
|
||||
o = r.get("optimized", {})
|
||||
rep = BacktestReport(
|
||||
strategy_id=r["id"],
|
||||
symbol=r.get("symbol", ""),
|
||||
timeframe=r.get("timeframe", "H1"),
|
||||
period_label="2021-2026",
|
||||
net_profit=float(o.get("net_profit", 0)),
|
||||
total_trades=int(o.get("total_trades", 0)),
|
||||
win_rate=float(o.get("win_rate", 0)),
|
||||
profit_factor=float(o.get("profit_factor", 0)),
|
||||
sharpe=float(o.get("sharpe", 0)),
|
||||
max_drawdown_pct=float(o.get("max_drawdown_pct", 0)),
|
||||
avg_win=float(o.get("avg_win", 0)),
|
||||
avg_loss=float(o.get("avg_loss", 0)),
|
||||
worst_trades=o.get("worst_trades", []),
|
||||
losing_trades=o.get("losing_trades", []),
|
||||
exit_reason_breakdown=o.get("exit_reason_breakdown", {}),
|
||||
monthly_returns=o.get("monthly_returns", {}),
|
||||
params=o.get("params", {}),
|
||||
)
|
||||
return acceptance(rep, days, DEFAULT_TRADES_PER_DAY)
|
||||
|
||||
|
||||
def patch_main_mqh(text: str, manifest: dict) -> str:
|
||||
params_by_sid = {k: v.get("params", {}) for k, v in manifest["strategies"].items()}
|
||||
|
||||
for input_name, (sid, pkey) in PARAM_PATCHES.items():
|
||||
params = params_by_sid.get(sid, {})
|
||||
if pkey not in params:
|
||||
continue
|
||||
val = params[pkey]
|
||||
if isinstance(val, bool):
|
||||
lit = "true" if val else "false"
|
||||
elif isinstance(val, float):
|
||||
lit = str(val) if "." in str(val) else f"{val}.0"
|
||||
else:
|
||||
lit = str(val)
|
||||
text, n = re.subn(
|
||||
rf"(^input\s+\w+\s+{re.escape(input_name)}\s*=\s*)[^;]+;",
|
||||
rf"\g<1>{lit};",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if n:
|
||||
print(f" patched {input_name}={lit}")
|
||||
|
||||
for enable_key, sid in ENABLE_PATCHES.items():
|
||||
info = manifest["strategies"].get(sid, {})
|
||||
if info.get("status") == "no_report" or "passed" not in info:
|
||||
continue
|
||||
if not info.get("passed"):
|
||||
continue # keep main.mq5 enable flags; only auto-enable winners
|
||||
lit = "true"
|
||||
text, n = re.subn(
|
||||
rf"(^input bool {re.escape(enable_key)}\s*=\s*)[^;]+;",
|
||||
rf"\g<1>{lit};",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if n:
|
||||
print(f" enable {enable_key}={lit}")
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def write_set_file(manifest: dict) -> None:
|
||||
lines = [
|
||||
"; UnitedEA_Optimized.set — generated from united sequential audit",
|
||||
f"; {datetime.now().isoformat()}",
|
||||
"",
|
||||
]
|
||||
for spec in UNITED_STRATEGIES:
|
||||
sid = spec["id"]
|
||||
info = manifest["strategies"].get(sid, {})
|
||||
passed = info.get("passed", False)
|
||||
lines.append(f"; {sid}: {'PASS' if passed else 'DISABLED'}")
|
||||
lines.append(f"{spec['enable_key']}={'true' if passed else 'false'}")
|
||||
lines.append(f"{spec['lot_key']}={spec['lot']}")
|
||||
for k, v in info.get("params", {}).items():
|
||||
lines.append(f"; {k}={v}")
|
||||
lines.append("")
|
||||
OUT_SET.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
start, end = PERIODS["2021-2026"]
|
||||
days = period_days(start, end)
|
||||
manifest: dict = {
|
||||
"generated": datetime.now().isoformat(),
|
||||
"period_days": days,
|
||||
"strategies": {},
|
||||
}
|
||||
|
||||
for spec in UNITED_STRATEGIES:
|
||||
sid = spec["id"]
|
||||
r = load_report(sid)
|
||||
if not r:
|
||||
manifest["strategies"][sid] = {"passed": False, "status": "no_report"}
|
||||
continue
|
||||
params = r.get("optimized_params") or r.get("optimized", {}).get("params", {})
|
||||
ok, issues = evaluate(r, days)
|
||||
o = r.get("optimized", {})
|
||||
manifest["strategies"][sid] = {
|
||||
"passed": ok,
|
||||
"enable_key": spec["enable_key"],
|
||||
"lot_key": spec["lot_key"],
|
||||
"lot": spec["lot"],
|
||||
"trades": o.get("total_trades"),
|
||||
"trades_per_day": round(trades_per_day(
|
||||
type("R", (), {"total_trades": int(o.get("total_trades", 0))})(), days), 3),
|
||||
"net_profit": o.get("net_profit"),
|
||||
"profit_factor": o.get("profit_factor"),
|
||||
"sharpe": o.get("sharpe"),
|
||||
"issues": issues,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
if MAIN_MQ5.exists():
|
||||
text = MAIN_MQ5.read_text(encoding="utf-8")
|
||||
print(f"Patching {MAIN_MQ5}")
|
||||
text = patch_main_mqh(text, manifest)
|
||||
MAIN_MQ5.write_text(text, encoding="utf-8")
|
||||
|
||||
REPORTS.mkdir(parents=True, exist_ok=True)
|
||||
OUT_JSON.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
write_set_file(manifest)
|
||||
passed = [k for k, v in manifest["strategies"].items() if v.get("passed")]
|
||||
print(f"Wrote {OUT_JSON}")
|
||||
print(f"Wrote {OUT_SET}")
|
||||
print(f"Passed {len(passed)}/{len(UNITED_STRATEGIES)}: {', '.join(passed) if passed else '(none)'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Timestamped trace logging for cluster audit runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TraceLog:
|
||||
def __init__(self, enabled: bool = True, trial_every: int = 10) -> None:
|
||||
self.enabled = enabled
|
||||
self.trial_every = max(1, trial_every)
|
||||
self._t0 = time.perf_counter()
|
||||
self._phase_t0 = self._t0
|
||||
|
||||
def _ts(self) -> str:
|
||||
return datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
def _elapsed(self) -> str:
|
||||
return f"{time.perf_counter() - self._t0:.1f}s"
|
||||
|
||||
def _phase_elapsed(self) -> str:
|
||||
return f"{time.perf_counter() - self._phase_t0:.1f}s"
|
||||
|
||||
def _write(self, level: str, msg: str) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
line = f"[{self._ts()} +{self._elapsed()}] [{level}] {msg}"
|
||||
print(line, flush=True)
|
||||
|
||||
def phase_start(self, name: str) -> None:
|
||||
self._phase_t0 = time.perf_counter()
|
||||
self._write("PHASE", f">> {name}")
|
||||
|
||||
def phase_end(self, name: str, detail: str = "") -> None:
|
||||
suffix = f" -- {detail}" if detail else ""
|
||||
self._write("PHASE", f"OK {name} ({self._phase_elapsed()}){suffix}")
|
||||
|
||||
def info(self, msg: str) -> None:
|
||||
self._write("INFO", msg)
|
||||
|
||||
def debug(self, msg: str) -> None:
|
||||
self._write("DEBUG", msg)
|
||||
|
||||
def warn(self, msg: str) -> None:
|
||||
self._write("WARN", msg)
|
||||
|
||||
def error(self, msg: str) -> None:
|
||||
self._write("ERROR", msg)
|
||||
|
||||
def banner(self, msg: str) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
bar = "=" * min(72, max(len(msg) + 4, 40))
|
||||
print(f"\n{bar}\n {msg}\n{bar}", flush=True)
|
||||
|
||||
def progress(self, current: int, total: int, label: str) -> None:
|
||||
pct = (100.0 * current / total) if total else 0.0
|
||||
self._write("PROGRESS", f"[{current}/{total} {pct:.0f}%] {label}")
|
||||
|
||||
def trial(self, n: int, total: int, score: float, net: float, sharpe: float, improved: bool) -> None:
|
||||
if n % self.trial_every != 0 and n != total and not improved:
|
||||
return
|
||||
flag = " ** NEW BEST" if improved else ""
|
||||
score_s = "N/A" if score == float("-inf") else f"{score:.2f}"
|
||||
self._write(
|
||||
"TRIAL",
|
||||
f"{n}/{total} score={score_s} net=${net:.0f} sharpe={sharpe:.2f}{flag}",
|
||||
)
|
||||
|
||||
def report_line(self, strategy_id: str, baseline: dict, optimized: dict, bars: int) -> None:
|
||||
b, o = baseline, optimized
|
||||
self._write(
|
||||
"RESULT",
|
||||
f"{strategy_id}: bars={bars} | "
|
||||
f"base net=${b['net_profit']:.0f} sh={b['sharpe']:.2f} trades={b['total_trades']} dd={b['max_drawdown_pct']:.1f}% | "
|
||||
f"opt net=${o['net_profit']:.0f} sh={o['sharpe']:.2f} trades={o['total_trades']} dd={o['max_drawdown_pct']:.1f}%",
|
||||
)
|
||||
if b.get("worst_trades"):
|
||||
w = b["worst_trades"][0]
|
||||
self.debug(
|
||||
f" worst loss: ${w['profit']:.2f} {w['side']} {w['exit_reason']} "
|
||||
f"({w['open_time']} -> {w['close_time']})"
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Per-strategy 'small trick' candidates for elimination audits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Each trick: human label + param overrides (applied on top of 123.set solo baseline).
|
||||
STRATEGY_TWEAKS: dict[str, list[dict]] = {
|
||||
"DB": [
|
||||
{"name": "close_on", "params": {"DB_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "tighter_sl", "params": {"DB_StopLoss": 1400}},
|
||||
{"name": "wider_tp", "params": {"DB_TakeProfit": 4200}},
|
||||
{"name": "shorter_box", "params": {"DB_BoxPeriod": 140}},
|
||||
{"name": "no_volume_filter", "params": {"DB_UseVolumeSpikeFilter": False}},
|
||||
{"name": "lower_trend_thresh", "params": {"DB_TrendThreshold": 3.8}},
|
||||
],
|
||||
"ES": [
|
||||
{"name": "tighter_trail", "params": {"ES_TrailingStop": 280}},
|
||||
{"name": "wider_trail", "params": {"ES_TrailingStop": 420}},
|
||||
{"name": "faster_profit_check", "params": {"ES_ProfitCheckBars": 12}},
|
||||
{"name": "stale_sl_exit", "params": {"ES_UseStaleStopLossExit": True}},
|
||||
{"name": "lower_adx_gate", "params": {"ES_WeeklyADXMin": 35}},
|
||||
{"name": "more_trades_per_x", "params": {"ES_MaxTradesPerCrossover": 12}},
|
||||
],
|
||||
"RC": [
|
||||
{"name": "close_on", "params": {"RC_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "short_cooldown", "params": {"RC_cooldownSeconds": 120}},
|
||||
{"name": "long_cooldown", "params": {"RC_cooldownSeconds": 300}},
|
||||
{"name": "tighter_trail", "params": {"RC_TrailingStop": 220}},
|
||||
{"name": "looser_ema_slope", "params": {"RC_emaSlopeThreshold": 90}},
|
||||
{"name": "rsi_exit_tighter", "params": {"RC_exitBuyRSI": 82, "RC_exitSellRSI": 14}},
|
||||
],
|
||||
"RM": [
|
||||
{"name": "close_on", "params": {"RM_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "short_reverse_cd", "params": {"RM_InpRSIReverseCooldownBars": 8}},
|
||||
{"name": "ema_cross_only", "params": {"RM_InpEnableRSIFollow": False, "RM_InpEnableRSIReverse": False}},
|
||||
{"name": "rsi_follow_only", "params": {"RM_InpEnableRSIReverse": False, "RM_InpEnableEMACross": False}},
|
||||
{"name": "tighter_ema_dist", "params": {"RM_InpEMADistancePips": 120}},
|
||||
{"name": "close_outside_hours", "params": {"RM_InpRSIFollowCloseOutsideHours": True}},
|
||||
],
|
||||
"RS_APPL": [
|
||||
{"name": "close_on", "params": {"RS_APPL_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "faster_bars", "params": {"RS_APPL_BarsToWait": 5}},
|
||||
{"name": "trail_activate_50", "params": {"RS_APPL_TrailActivationPoints": 50}},
|
||||
{"name": "tighter_trail", "params": {"RS_APPL_TrailDistancePoints": 80}},
|
||||
{"name": "wider_targets", "params": {"RS_APPL_RSI_Target_Buy": 92, "RS_APPL_RSI_Target_Sell": 40}},
|
||||
],
|
||||
"RS_ADBE": [
|
||||
{"name": "close_on", "params": {"RS_ADBE_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "faster_bars", "params": {"RS_ADBE_BarsToWait": 6}},
|
||||
{"name": "trail_activate_10", "params": {"RS_ADBE_TrailActivationPoints": 10}},
|
||||
{"name": "tighter_trail", "params": {"RS_ADBE_TrailDistancePoints": 350}},
|
||||
],
|
||||
"RS_BTCUSD": [
|
||||
{"name": "close_on", "params": {"RS_BTCUSD_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "faster_bars", "params": {"RS_BTCUSD_BarsToWait": 4}},
|
||||
{"name": "trail_activate_30", "params": {"RS_BTCUSD_TrailActivationPoints": 30}},
|
||||
{"name": "tighter_trail", "params": {"RS_BTCUSD_TrailDistancePoints": 90}},
|
||||
{"name": "reversal_escape_off", "params": {"RS_UseReversalEscape": False}},
|
||||
],
|
||||
"RS_NVDA": [
|
||||
{"name": "close_on", "params": {"RS_NVDA_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "faster_bars", "params": {"RS_NVDA_BarsToWait": 3}},
|
||||
{"name": "tighter_trail", "params": {"RS_NVDA_TrailDistancePoints": 280}},
|
||||
{"name": "lower_trail_act", "params": {"RS_NVDA_TrailActivationPoints": 50}},
|
||||
{"name": "wider_ob_os", "params": {"RS_NVDA_RSI_Overbought": 40, "RS_NVDA_RSI_Oversold": 35}},
|
||||
],
|
||||
"RS_TSLA": [
|
||||
{"name": "faster_bars", "params": {"RS_TSLA_BarsToWait": 2}},
|
||||
{"name": "trail_act_400", "params": {"RS_TSLA_TrailActivationPoints": 400}},
|
||||
{"name": "trail_act_600", "params": {"RS_TSLA_TrailActivationPoints": 600}},
|
||||
{"name": "tighter_trail", "params": {"RS_TSLA_TrailDistancePoints": 700}},
|
||||
{"name": "wider_trail", "params": {"RS_TSLA_TrailDistancePoints": 1100}},
|
||||
],
|
||||
"RS_XAUUSD": [
|
||||
{"name": "faster_bars", "params": {"RS_XAUUSD_BarsToWait": 3}},
|
||||
{"name": "lower_trail_act", "params": {"RS_XAUUSD_TrailActivationPoints": 25}},
|
||||
{"name": "tighter_trail", "params": {"RS_XAUUSD_TrailDistancePoints": 55}},
|
||||
{"name": "wider_trail", "params": {"RS_XAUUSD_TrailDistancePoints": 90}},
|
||||
{"name": "reversal_escape_off", "params": {"RS_UseReversalEscape": False}},
|
||||
],
|
||||
"RS_MU": [
|
||||
{"name": "close_on", "params": {"RS_MU_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "faster_bars", "params": {"RS_MU_BarsToWait": 20}},
|
||||
{"name": "symbol_munas", "params": {"RS_MU_Symbol": "MU.NAS"}},
|
||||
{"name": "symbol_muus", "params": {"RS_MU_Symbol": "MU.US"}},
|
||||
{"name": "looser_ob_os", "params": {"RS_MU_RSI_Overbought": 40, "RS_MU_RSI_Oversold": 75}},
|
||||
],
|
||||
"SE": [
|
||||
{"name": "close_on", "params": {"SE_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "exit_trend_flip", "params": {"SE_ExitOnTrendFlip": True}},
|
||||
{"name": "shorter_hold", "params": {"SE_MaxHoldingBars": 120}},
|
||||
{"name": "longer_hold", "params": {"SE_MaxHoldingBars": 220}},
|
||||
{"name": "structural_sl", "params": {"SE_UseStructuralSL": True}},
|
||||
{"name": "cci_looser", "params": {"SE_CciOversold": -120}},
|
||||
],
|
||||
"RCO": [
|
||||
{"name": "close_on", "params": {"RCO_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "higher_adx_cap", "params": {"RCO_ADX_Max": 32}},
|
||||
{"name": "tighter_sl", "params": {"RCO_SL_ATR_Mult": 1.9}},
|
||||
{"name": "wider_tp", "params": {"RCO_TP_ATR_Mult": 2.7}},
|
||||
{"name": "shorter_max_bars", "params": {"RCO_MaxBarsInTrade": 40}},
|
||||
],
|
||||
"RRA_EUR": [
|
||||
{"name": "close_on", "params": {"RRA_EURUSD_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "shorter_duration", "params": {"RRA_EURUSD_MaxDuration": 200}},
|
||||
{"name": "close_outside", "params": {"RRA_EURUSD_CloseOutsideSession": True}},
|
||||
{"name": "rsi_exit_50", "params": {"RRA_EURUSD_RSIExitLevel": 50}},
|
||||
{"name": "tighter_os", "params": {"RRA_EURUSD_OversoldLevel": 12}},
|
||||
],
|
||||
"RRA_AUD": [
|
||||
{"name": "close_on", "params": {"RRA_AUDUSD_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "shorter_duration", "params": {"RRA_AUDUSD_MaxDuration": 250}},
|
||||
{"name": "no_close_outside", "params": {"RRA_AUDUSD_CloseOutsideSession": False}},
|
||||
{"name": "tighter_os", "params": {"RRA_AUDUSD_OversoldLevel": 28}},
|
||||
{"name": "rsi_exit_45", "params": {"RRA_AUDUSD_RSIExitLevel": 45}},
|
||||
],
|
||||
"ST_BTC": [
|
||||
{"name": "close_on", "params": {"ST_BTC_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "tighter_break", "params": {"ST_BTC_BreakBuffer": 70}},
|
||||
{"name": "looser_touch", "params": {"ST_BTC_LineTouchTolerance": 150}},
|
||||
{"name": "longer_ma", "params": {"ST_BTC_MAPeriod": 180}},
|
||||
],
|
||||
"ST_XAU": [
|
||||
{"name": "close_on", "params": {"ST_XAU_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "tighter_break", "params": {"ST_XAU_BreakBuffer": 90}},
|
||||
{"name": "shorter_ma", "params": {"ST_XAU_MAPeriod": 55}},
|
||||
{"name": "looser_touch", "params": {"ST_XAU_LineTouchTolerance": 260}},
|
||||
],
|
||||
"ST_GER": [
|
||||
{"name": "close_on", "params": {"ST_GER_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "tighter_break", "params": {"ST_GER_BreakBuffer": 60}},
|
||||
{"name": "looser_touch", "params": {"ST_GER_LineTouchTolerance": 120}},
|
||||
{"name": "longer_ma", "params": {"ST_GER_MAPeriod": 80}},
|
||||
],
|
||||
"RSS": [
|
||||
{"name": "close_on", "params": {"RSS_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "swing_sl", "params": {"RSS_UseSwingStopLoss": True}},
|
||||
{"name": "shorter_cooldown", "params": {"RSS_MinBarsBetweenTrades": 4}},
|
||||
{"name": "tighter_sl_atr", "params": {"RSS_StopLossATR": 2.2}},
|
||||
],
|
||||
"UB": [
|
||||
{"name": "close_on", "params": {"UB_CloseUnprofitableOnNewSignal": True}},
|
||||
{"name": "min_range_12", "params": {"UB_MinRangePoints": 12}},
|
||||
{"name": "order_buf_35", "params": {"UB_OrderBufferPoints": 3.5}},
|
||||
{"name": "first_trade_only", "params": {"UB_FirstTradeOnly": True}},
|
||||
{"name": "wider_range", "params": {"UB_MinRangePoints": 18}},
|
||||
{"name": "tighter_buffer", "params": {"UB_OrderBufferPoints": 3.0}},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"""United EA sub-strategy manifest for MT5 solo audits (123.set)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
UNITED_MT5_STRATEGIES: list[dict] = [
|
||||
{"id": "DB", "name": "DarvasBox", "enable": "EnableDarvasBox", "close": "DB_CloseUnprofitableOnNewSignal", "lot": "LOT_DB_DarvasBox"},
|
||||
{"id": "ES", "name": "EMASlopeDistance", "enable": "EnableEMASlopeDistance", "close": "ES_CloseUnprofitableOnNewSignal", "lot": "LOT_ES_EMASlopeDistance"},
|
||||
{"id": "RC", "name": "RSICrossOverReversal", "enable": "EnableRSICrossOverReversal", "close": "RC_CloseUnprofitableOnNewSignal", "lot": "LOT_RC_RSICrossOver"},
|
||||
{"id": "RM", "name": "RSIMidPointHijack", "enable": "EnableRSIMidPointHijack", "close": "RM_CloseUnprofitableOnNewSignal", "lot": "LOT_RM_RSIMidPointHijack"},
|
||||
{"id": "RS_APPL", "name": "RSIScalping APPL", "enable": "EnableRSIScalpingAPPL", "close": "RS_APPL_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_APPL", "test_symbol": "AAPL.NAS", "lot_class": "stock"},
|
||||
{"id": "RS_ADBE", "name": "RSIScalping ADBE", "enable": "EnableRSIScalpingADBE", "close": "RS_ADBE_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_ADBE", "test_symbol": "ADBE.NAS", "lot_class": "stock"},
|
||||
{"id": "RS_BTCUSD", "name": "RSIScalping BTCUSD", "enable": "EnableRSIScalpingBTCUSD", "close": "RS_BTCUSD_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_BTCUSD"},
|
||||
{"id": "RS_NVDA", "name": "RSIScalping NVDA", "enable": "EnableRSIScalpingNVDA", "close": "RS_NVDA_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_NVDA", "test_symbol": "NVDA.NAS", "lot_class": "stock"},
|
||||
{"id": "RS_TSLA", "name": "RSIScalping TSLA", "enable": "EnableRSIScalpingTSLA", "close": "RS_TSLA_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_TSLA", "test_symbol": "TSLA.NAS", "lot_class": "stock"},
|
||||
{"id": "RS_XAUUSD", "name": "RSIScalping XAUUSD", "enable": "EnableRSIScalpingXAUUSD", "close": "RS_XAUUSD_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_XAUUSD"},
|
||||
{"id": "RS_MU", "name": "RSIScalping MU", "enable": "EnableRSIScalpingMU", "close": "RS_MU_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_MU"},
|
||||
{"id": "SE", "name": "SuperEMA", "enable": "EnableSuperEMA", "close": "SE_CloseUnprofitableOnNewSignal", "lot": "LOT_SE_SuperEMA"},
|
||||
{"id": "RCO", "name": "RSIConsolidation", "enable": "EnableRSIConsolidation", "close": "RCO_CloseUnprofitableOnNewSignal", "lot": "LOT_RCO_RSIConsolidation"},
|
||||
{"id": "RRA_EUR", "name": "RSI Asian EURUSD", "enable": "EnableRSIReversalAsianEURUSD", "close": "RRA_EURUSD_CloseUnprofitableOnNewSignal", "lot": "LOT_RRA_EURUSD"},
|
||||
{"id": "RRA_AUD", "name": "RSI Asian AUDUSD", "enable": "EnableRSIReversalAsianAUDUSD", "close": "RRA_AUDUSD_CloseUnprofitableOnNewSignal", "lot": "LOT_RRA_AUDUSD"},
|
||||
{"id": "ST_BTC", "name": "SimpleTrendline BTC", "enable": "EnableSimpleTrendlineBTCUSD", "close": "ST_BTC_CloseUnprofitableOnNewSignal", "lot": "LOT_ST_BTCUSD"},
|
||||
{"id": "ST_XAU", "name": "SimpleTrendline XAU", "enable": "EnableSimpleTrendlineXAUUSD", "close": "ST_XAU_CloseUnprofitableOnNewSignal", "lot": "LOT_ST_XAUUSD"},
|
||||
{"id": "ST_GER", "name": "SimpleTrendline GER40", "enable": "EnableSimpleTrendlineGER40", "close": "ST_GER_CloseUnprofitableOnNewSignal", "lot": "LOT_ST_GER40"},
|
||||
{"id": "RSS", "name": "RSISecretSauce", "enable": "EnableRSISecretSauce", "close": "RSS_CloseUnprofitableOnNewSignal", "lot": "LOT_RSS_SecretSauce"},
|
||||
{"id": "UB", "name": "USDJPYBuster", "enable": "EnableUSDJPYBuster", "close": "UB_CloseUnprofitableOnNewSignal", "lot": "LOT_UB_USDJPY"},
|
||||
{"id": "XBT", "name": "XAUBearTrend", "enable": "EnableXAUBearTrend", "close": "XBT_CloseUnprofitableOnNewSignal", "lot": "LOT_XBT_XAUUSD"},
|
||||
{"id": "XMB", "name": "XAUMomentumBreakdown", "enable": "EnableXAUMomentumBreakdown", "close": "XMB_CloseUnprofitableOnNewSignal", "lot": "LOT_XMB_XAUUSD"},
|
||||
{"id": "RRA_GBP", "name": "RSI Asian GBPUSD", "enable": "EnableRSIReversalAsianGBPUSD", "close": "RRA_GBPUSD_CloseUnprofitableOnNewSignal", "lot": "LOT_RRA_GBPUSD"},
|
||||
{"id": "GB", "name": "GER40Buster", "enable": "EnableGER40Buster", "close": "GB_CloseUnprofitableOnNewSignal", "lot": "LOT_GB_GER40"},
|
||||
{"id": "RS_NAS100", "name": "RSIScalping NAS100", "enable": "EnableRSIScalpingNAS100", "close": "RS_NAS100_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_NAS100"},
|
||||
{"id": "RS_US500", "name": "RSIScalping US500", "enable": "EnableRSIScalpingUS500", "close": "RS_US500_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_US500"},
|
||||
{"id": "RRA_USDCHF", "name": "RSI Asian USDCHF", "enable": "EnableRSIReversalAsianUSDCHF", "close": "RRA_USDCHF_CloseUnprofitableOnNewSignal", "lot": "LOT_RRA_USDCHF"},
|
||||
{"id": "RRA_NZDUSD", "name": "RSI Asian NZDUSD", "enable": "EnableRSIReversalAsianNZDUSD", "close": "RRA_NZDUSD_CloseUnprofitableOnNewSignal", "lot": "LOT_RRA_NZDUSD"},
|
||||
{"id": "NB", "name": "NAS100Buster", "enable": "EnableNAS100Buster", "close": "NB_CloseUnprofitableOnNewSignal", "lot": "LOT_NB_NAS100"},
|
||||
{"id": "U5B", "name": "US500Buster", "enable": "EnableUS500Buster", "close": "U5B_CloseUnprofitableOnNewSignal", "lot": "LOT_U5B_US500"},
|
||||
{"id": "RS_US30", "name": "RSIScalping US30", "enable": "EnableRSIScalpingUS30", "close": "RS_US30_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_US30"},
|
||||
{"id": "RS_XAGUSD", "name": "RSIScalping XAGUSD", "enable": "EnableRSIScalpingXAGUSD", "close": "RS_XAGUSD_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_XAGUSD"},
|
||||
{"id": "RS_EURJPY", "name": "RSIScalping EURJPY", "enable": "EnableRSIScalpingEURJPY", "close": "RS_EURJPY_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_EURJPY"},
|
||||
{"id": "RS_GBPJPY", "name": "RSIScalping GBPJPY", "enable": "EnableRSIScalpingGBPJPY", "close": "RS_GBPJPY_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_GBPJPY"},
|
||||
{"id": "U30B", "name": "US30Buster", "enable": "EnableUS30Buster", "close": "U30B_CloseUnprofitableOnNewSignal", "lot": "LOT_U30B_US30"},
|
||||
{"id": "UKB", "name": "UK100Buster", "enable": "EnableUK100Buster", "close": "UKB_CloseUnprofitableOnNewSignal", "lot": "LOT_UKB_UK100"},
|
||||
{"id": "XGB", "name": "XAGUSDBuster", "enable": "EnableXAGUSDBuster", "close": "XGB_CloseUnprofitableOnNewSignal", "lot": "LOT_XGB_XAGUSD"},
|
||||
{"id": "RS_F", "name": "RSIScalping F", "enable": "EnableRSIScalpingF", "close": "RS_F_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_F", "test_symbol": "F.NYS"},
|
||||
{"id": "RS_SOFI", "name": "RSIScalping SOFI", "enable": "EnableRSIScalpingSOFI", "close": "RS_SOFI_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_SOFI", "test_symbol": "SOFI.NAS"},
|
||||
{"id": "RS_SNAP", "name": "RSIScalping SNAP", "enable": "EnableRSIScalpingSNAP", "close": "RS_SNAP_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_SNAP", "test_symbol": "SNAP.NYS"},
|
||||
{"id": "RS_WBD", "name": "RSIScalping WBD", "enable": "EnableRSIScalpingWBD", "close": "RS_WBD_CloseUnprofitableOnNewSignal", "lot": "LOT_RS_WBD", "test_symbol": "WBD.NAS"},
|
||||
]
|
||||
|
||||
ALL_ENABLE_KEYS = [s["enable"] for s in UNITED_MT5_STRATEGIES]
|
||||
|
||||
# Round-1 expansion (retired except survivor).
|
||||
EXPANSION_RETIRED_IDS = (
|
||||
"XBT", "XMB", "RRA_GBP", "GB", "RS_US500", "RRA_USDCHF", "RRA_NZDUSD", "NB", "U5B",
|
||||
)
|
||||
|
||||
# Survivor kept on baseline + enhanced.
|
||||
SURVIVOR_IDS = ("RS_NAS100", "RS_US30", "UKB")
|
||||
|
||||
# Round-2 candidates (audited; non-survivors stay off).
|
||||
ROUND2_IDS = (
|
||||
"RS_US30", "RS_XAGUSD", "RS_EURJPY", "RS_GBPJPY", "U30B", "UKB", "XGB",
|
||||
)
|
||||
|
||||
# Round-3 low-margin stock candidates (~$1–2 margin per share @ 5% leverage).
|
||||
ROUND3_IDS = ("RS_F", "RS_SOFI", "RS_SNAP", "RS_WBD")
|
||||
|
||||
# High share-price stocks — force off in expansion audits (margin call risk).
|
||||
HIGH_MARGIN_STOCK_ENABLES = ("EnableRSIScalpingMU",)
|
||||
|
||||
# Production cluster (matches main.mq5 defaults).
|
||||
PRODUCTION_IDS: tuple[str, ...] = (
|
||||
"DB", "ES", "RC", "RM",
|
||||
"RS_NVDA", "RS_TSLA",
|
||||
"RS_BTCUSD", "RS_XAUUSD", "SE", "ST_BTC", "ST_XAU",
|
||||
"RRA_AUD", "RRA_GBP", "UB",
|
||||
"RS_NAS100", "RS_US30", "UKB", "GB", "U5B",
|
||||
)
|
||||
|
||||
LOT_GRIDS: dict[str, list[float]] = {
|
||||
"stock": [5.0, 10.0, 15.0],
|
||||
"index": [0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1],
|
||||
"forex": [0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1],
|
||||
"gold": [0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1],
|
||||
"crypto": [0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1],
|
||||
}
|
||||
|
||||
LOT_GENETIC_RANGE: dict[str, tuple[float, float, float]] = {
|
||||
"stock": (5.0, 5.0, 15.0),
|
||||
"default": (0.01, 0.01, 0.1),
|
||||
}
|
||||
|
||||
LOT_CLASS_BY_ID: dict[str, str] = {
|
||||
"DB": "gold", "ES": "gold", "RC": "gold", "RM": "gold",
|
||||
"RS_XAUUSD": "gold", "ST_XAU": "gold", "XBT": "gold", "XMB": "gold", "XGB": "gold",
|
||||
"RS_APPL": "stock", "RS_ADBE": "stock", "RS_NVDA": "stock", "RS_TSLA": "stock", "RS_MU": "stock",
|
||||
"RS_F": "stock", "RS_SOFI": "stock", "RS_SNAP": "stock", "RS_WBD": "stock",
|
||||
"RS_NAS100": "index", "RS_US500": "index", "RS_US30": "index", "UKB": "index",
|
||||
"NB": "index", "U5B": "index", "U30B": "index", "GB": "index",
|
||||
"RRA_EUR": "forex", "RRA_AUD": "forex", "RRA_GBP": "forex", "RRA_USDCHF": "forex", "RRA_NZDUSD": "forex",
|
||||
"UB": "forex", "RS_EURJPY": "forex", "RS_GBPJPY": "forex",
|
||||
"RS_BTCUSD": "crypto", "ST_BTC": "crypto",
|
||||
"ST_GER": "index", "RS_XAGUSD": "gold",
|
||||
}
|
||||
|
||||
PARAM_TWEAKS: dict[str, list[dict]] = {
|
||||
"ES": [{"ES_TrailingStop": 250}, {"ES_TrailingStop": 300, "ES_ProfitCheckBars": 12}],
|
||||
"RC": [{"RC_cooldownSeconds": 120}, {"RC_TrailingStop": 250}],
|
||||
"RS_NVDA": [{"RS_NVDA_BarsToWait": 3}, {"RS_NVDA_TrailDistancePoints": 300}],
|
||||
"RS_TSLA": [{"RS_TSLA_BarsToWait": 2}, {"RS_TSLA_TrailActivationPoints": 500}],
|
||||
"RCO": [{"RCO_ADX_Max": 32}, {"RCO_SL_ATR_Mult": 1.9}],
|
||||
"UB": [{"UB_MinRangePoints": 12}, {"UB_OrderBufferPoints": 3.5}],
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
"""MT5 Strategy Tester runner for United EA (cluster-latest/main.mq5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
|
||||
CLUSTER = Path(__file__).resolve().parents[3] / "frontline" / "cluster-latest"
|
||||
BASE_SET = CLUSTER / "123.set"
|
||||
DEPOSIT = 3000
|
||||
LEVERAGE = 1000
|
||||
FROM_DATE = "2023.07.01"
|
||||
TO_DATE = "2026.06.01"
|
||||
TEST_SYMBOL = "NAS100"
|
||||
TEST_PERIOD = "H1"
|
||||
|
||||
LABELS = {
|
||||
"profit_factor": ("Profit Factor", "盈利因子"),
|
||||
"net_profit": ("Total Net Profit", "总净盈利"),
|
||||
"total_trades": ("Total Trades", "交易总计"),
|
||||
"sharpe": ("Sharpe Ratio", "夏普比率"),
|
||||
"equity_dd": ("Equity Drawdown Maximal", "最大回撤"),
|
||||
"margin_level": ("Minimal margin level", "最低保证金比例"),
|
||||
}
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
raw = path.read_bytes()
|
||||
for enc in ("utf-16", "utf-16-le", "utf-8", "cp1252"):
|
||||
try:
|
||||
text = raw.decode(enc)
|
||||
if text.strip():
|
||||
return text
|
||||
except UnicodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
def grab_metric(text: str, key: str) -> str | None:
|
||||
for label in LABELS[key]:
|
||||
for pat in (
|
||||
rf">{re.escape(label)}</td>\s*<td[^>]*>(?:<b>)?([^<]+)",
|
||||
rf">{re.escape(label)}:</td>\s*<td[^>]*>(?:<b>)?([^<]+)",
|
||||
rf">{re.escape(label)}</td>\s*<td[^>]*><b>([^<]+)",
|
||||
):
|
||||
m = re.search(pat, text, re.I)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return None
|
||||
|
||||
|
||||
def parse_report(data: Path, report: str) -> dict:
|
||||
candidates = [data / f"{report}{ext}" for ext in (".htm", ".html")]
|
||||
candidates += sorted(data.glob(f"**/{report}*.htm*"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
seen: set[Path] = set()
|
||||
for path in candidates:
|
||||
if path in seen or not path.exists():
|
||||
continue
|
||||
seen.add(path)
|
||||
text = read_text(path)
|
||||
pf = grab_metric(text, "profit_factor")
|
||||
profit = grab_metric(text, "net_profit")
|
||||
trades = grab_metric(text, "total_trades")
|
||||
sharpe = grab_metric(text, "sharpe")
|
||||
dd = grab_metric(text, "equity_dd")
|
||||
ml = grab_metric(text, "margin_level")
|
||||
if pf or profit or trades:
|
||||
return {
|
||||
"profit_factor": float(pf) if pf else None,
|
||||
"net_profit": _num(profit),
|
||||
"total_trades": int(float(trades)) if trades and trades[0].isdigit() else _int(trades),
|
||||
"sharpe": float(sharpe) if sharpe else None,
|
||||
"max_drawdown": dd,
|
||||
"min_margin_level": ml,
|
||||
"report": str(path),
|
||||
"ready": True,
|
||||
}
|
||||
return {"ready": False}
|
||||
|
||||
|
||||
def _num(s: str | None) -> float | None:
|
||||
if not s:
|
||||
return None
|
||||
s = s.replace(" ", "").replace(",", "")
|
||||
if s.endswith("%"):
|
||||
return float(s[:-1])
|
||||
return float(s)
|
||||
|
||||
|
||||
def _int(s: str | None) -> int | None:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return int(float(s.replace(" ", "").replace(",", "")))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
KNOWN_TERMINALS = [
|
||||
Path(r"C:\Program Files\MetaTrader 5\terminal64.exe"),
|
||||
]
|
||||
|
||||
|
||||
def _terminal_data_dirs() -> list[Path]:
|
||||
root = Path.home() / "AppData" / "Roaming" / "MetaQuotes" / "Terminal"
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return [p for p in root.iterdir() if p.is_dir() and (p / "origin.txt").exists()]
|
||||
|
||||
|
||||
def _read_origin(path: Path) -> str:
|
||||
raw = path.read_bytes()
|
||||
for enc in ("utf-8", "utf-16", "utf-16-le", "cp1252"):
|
||||
try:
|
||||
return raw.decode(enc).strip()
|
||||
except UnicodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="ignore").strip()
|
||||
|
||||
|
||||
def _load_dotenv() -> None:
|
||||
env_path = Path(__file__).resolve().parents[3] / ".env"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, val = line.split("=", 1)
|
||||
key, val = key.strip(), val.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = val
|
||||
|
||||
|
||||
_load_dotenv()
|
||||
MT5_TERMINAL_DATA_ID = os.environ.get("MT5_TERMINAL_DATA_ID", "").strip()
|
||||
# Back-compat alias for scripts that import PREFERRED_DATA_ID
|
||||
PREFERRED_DATA_ID = MT5_TERMINAL_DATA_ID
|
||||
|
||||
|
||||
def mt5_terminal_data_dir() -> Path | None:
|
||||
"""MT5 data folder: MT5_TERMINAL_DATA_ID env, else first terminal with origin.txt."""
|
||||
root = Path.home() / "AppData" / "Roaming" / "MetaQuotes" / "Terminal"
|
||||
if MT5_TERMINAL_DATA_ID:
|
||||
candidate = root / MT5_TERMINAL_DATA_ID
|
||||
if (candidate / "origin.txt").is_file():
|
||||
return candidate
|
||||
dirs = _terminal_data_dirs()
|
||||
return dirs[0] if dirs else None
|
||||
|
||||
|
||||
def _resolve_terminal_exe() -> Path | None:
|
||||
env_exe = os.environ.get("MT5_TERMINAL_EXE", "").strip()
|
||||
if env_exe:
|
||||
exe = Path(env_exe)
|
||||
if exe.is_file():
|
||||
return exe
|
||||
data_dir = mt5_terminal_data_dir()
|
||||
if data_dir:
|
||||
origin = data_dir / "origin.txt"
|
||||
if origin.is_file():
|
||||
try:
|
||||
exe = Path(_read_origin(origin))
|
||||
if exe.is_file():
|
||||
return exe
|
||||
except OSError:
|
||||
pass
|
||||
for data_dir in _terminal_data_dirs():
|
||||
origin = data_dir / "origin.txt"
|
||||
try:
|
||||
exe = Path(_read_origin(origin))
|
||||
if exe.is_file():
|
||||
return exe
|
||||
except OSError:
|
||||
continue
|
||||
for exe in KNOWN_TERMINALS:
|
||||
if exe.is_file():
|
||||
return exe
|
||||
return None
|
||||
|
||||
|
||||
def mt5_context(*, retries: int = 6, wait_sec: float = 12.0) -> dict:
|
||||
terminal_exe = _resolve_terminal_exe()
|
||||
last_err = None
|
||||
for attempt in range(retries):
|
||||
if attempt:
|
||||
time.sleep(wait_sec)
|
||||
if attempt >= 1 and terminal_exe and terminal_exe.is_file():
|
||||
subprocess.run(["taskkill", "/IM", "terminal64.exe", "/F"], capture_output=True)
|
||||
time.sleep(3)
|
||||
subprocess.Popen([str(terminal_exe)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
time.sleep(20)
|
||||
mt5.shutdown()
|
||||
ok = mt5.initialize(path=str(terminal_exe)) if terminal_exe else mt5.initialize()
|
||||
if not ok:
|
||||
last_err = mt5.last_error()
|
||||
continue
|
||||
info = mt5.terminal_info()
|
||||
acc = mt5.account_info()
|
||||
ctx = {
|
||||
"data": Path(info.data_path),
|
||||
"mt5_path": Path(info.path),
|
||||
"login": acc.login if acc else 0,
|
||||
"server": acc.server if acc else "",
|
||||
}
|
||||
mt5.shutdown()
|
||||
return ctx
|
||||
raise RuntimeError(f"MT5 init failed after {retries} tries: {last_err}")
|
||||
|
||||
|
||||
def deploy_united(data: Path, mt5_path: Path) -> Path:
|
||||
dst = data / "MQL5" / "Experts" / "cluster-latest"
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
for name in ("main.mq5", "MagicNumberHelpers.mqh", "GapGuard.mqh"):
|
||||
shutil.copy2(CLUSTER / name, dst / name)
|
||||
strat_dst = dst / "Strategies"
|
||||
strat_dst.mkdir(exist_ok=True)
|
||||
for f in (CLUSTER / "Strategies").glob("*.mqh"):
|
||||
shutil.copy2(f, strat_dst / f.name)
|
||||
log = dst / "compile.log"
|
||||
subprocess.run(
|
||||
[str(mt5_path / "metaeditor64.exe"), f"/compile:{dst / 'main.mq5'}", f"/log:{log}"],
|
||||
timeout=180,
|
||||
capture_output=True,
|
||||
)
|
||||
time.sleep(3)
|
||||
ex5 = dst / "main.ex5"
|
||||
if not ex5.exists():
|
||||
tail = log.read_text(encoding="utf-8", errors="ignore")[-2000:] if log.exists() else ""
|
||||
raise RuntimeError(f"Compile failed: {log}\n{tail}")
|
||||
pub = data / "MQL5" / "Experts" / "main.ex5"
|
||||
shutil.copy2(ex5, pub)
|
||||
return pub
|
||||
|
||||
|
||||
def patch_set(base: Path, overrides: dict[str, str | bool | int | float]) -> str:
|
||||
lines_out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for line in base.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
if not line.strip() or line.strip().startswith(";") or "=" not in line:
|
||||
lines_out.append(line)
|
||||
continue
|
||||
name = line.split("=", 1)[0].strip()
|
||||
if name in overrides:
|
||||
val = overrides[name]
|
||||
if isinstance(val, bool):
|
||||
sval = "true" if val else "false"
|
||||
else:
|
||||
sval = str(val)
|
||||
lines_out.append(f"{name}={sval}")
|
||||
seen.add(name)
|
||||
else:
|
||||
lines_out.append(line)
|
||||
for k, v in overrides.items():
|
||||
if k not in seen:
|
||||
sval = "true" if v is True else "false" if v is False else str(v)
|
||||
lines_out.append(f"{k}={sval}")
|
||||
return "\n".join(lines_out) + "\n"
|
||||
|
||||
|
||||
def patch_set_for_lot_genetic(
|
||||
base: Path,
|
||||
overrides: dict[str, str | bool | int | float],
|
||||
lot_key: str,
|
||||
start: float,
|
||||
step: float,
|
||||
stop: float,
|
||||
default: float | None = None,
|
||||
) -> str:
|
||||
"""Build .set with a single LOT_* genetic range; all other ||Y flags forced to N."""
|
||||
body = patch_set(base, overrides)
|
||||
val = default if default is not None else start
|
||||
genetic_line = f"{lot_key}={val}||{start}||{step}||{stop}||Y"
|
||||
lines_out: list[str] = []
|
||||
seen_lot = False
|
||||
for line in body.splitlines():
|
||||
if not line.strip() or line.strip().startswith(";") or "=" not in line:
|
||||
lines_out.append(line)
|
||||
continue
|
||||
name = line.split("=", 1)[0].strip()
|
||||
if name == lot_key:
|
||||
lines_out.append(genetic_line)
|
||||
seen_lot = True
|
||||
elif "||" in line:
|
||||
parts = line.split("||")
|
||||
if len(parts) >= 5:
|
||||
parts[4] = "N"
|
||||
lines_out.append("||".join(parts))
|
||||
else:
|
||||
lines_out.append(line)
|
||||
else:
|
||||
lines_out.append(line)
|
||||
if not seen_lot:
|
||||
lines_out.append(genetic_line)
|
||||
return "\n".join(lines_out) + "\n"
|
||||
|
||||
|
||||
def parse_optimization_xml(data: Path, report: str, lot_key: str) -> dict:
|
||||
candidates = [data / f"{report}.xml", data / f"{report}.opt"]
|
||||
candidates += sorted(data.glob(f"**/{report}*.xml"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
seen: set[Path] = set()
|
||||
xml_path: Path | None = None
|
||||
for p in candidates:
|
||||
if p in seen or not p.exists() or p.suffix.lower() != ".xml":
|
||||
continue
|
||||
seen.add(p)
|
||||
xml_path = p
|
||||
break
|
||||
if xml_path is None:
|
||||
return {"ready": False, "error": "xml_not_found"}
|
||||
|
||||
text = read_text(xml_path)
|
||||
header = re.search(r"<Row>.*?Pass</Data>.*?</Row>", text, re.S)
|
||||
if not header:
|
||||
return {"ready": False, "error": "xml_header_missing", "report": str(xml_path)}
|
||||
|
||||
cols = re.findall(r'<Data ss:Type="String">([^<]+)</Data>', header.group(0))
|
||||
rows: list[dict[str, str]] = []
|
||||
for row_xml in re.findall(r"<Row>(.*?)</Row>", text, re.S)[1:]:
|
||||
cells = re.findall(r'<Data ss:Type="(?:Number|String)">([^<]+)</Data>', row_xml)
|
||||
if len(cells) >= len(cols):
|
||||
rows.append(dict(zip(cols, cells)))
|
||||
|
||||
if not rows:
|
||||
return {"ready": False, "error": "xml_no_rows", "report": str(xml_path)}
|
||||
|
||||
best_row: dict[str, str] | None = None
|
||||
best_score = -1e18
|
||||
for row in rows:
|
||||
try:
|
||||
profit = float(row.get("Profit", 0))
|
||||
pf = float(row.get("Profit Factor", 0))
|
||||
sharpe = float(row.get("Sharpe Ratio", row.get("Sharpe", 0)))
|
||||
trades = int(float(row.get("Trades", 0)))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if trades < 20 or pf < 1.0 or profit <= 0:
|
||||
score = -1e10 + profit
|
||||
else:
|
||||
score = sharpe * 2000.0 + profit / 500.0 + pf * 50.0
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_row = row
|
||||
|
||||
if best_row is None:
|
||||
best_row = max(rows, key=lambda r: float(r.get("Profit", 0)))
|
||||
|
||||
lot_raw = best_row.get(lot_key)
|
||||
if lot_raw is None:
|
||||
for k, v in best_row.items():
|
||||
if k.replace(" ", "") == lot_key or lot_key in k:
|
||||
lot_raw = v
|
||||
break
|
||||
best_lot = float(lot_raw) if lot_raw is not None else None
|
||||
|
||||
return {
|
||||
"ready": True,
|
||||
"report": str(xml_path),
|
||||
"best_lot": best_lot,
|
||||
"best_row": best_row,
|
||||
"best_score": best_score,
|
||||
"passes": len(rows),
|
||||
"profit": float(best_row.get("Profit", 0)),
|
||||
"profit_factor": float(best_row.get("Profit Factor", 0)),
|
||||
"sharpe": float(best_row.get("Sharpe Ratio", best_row.get("Sharpe", 0))),
|
||||
"trades": int(float(best_row.get("Trades", 0))),
|
||||
}
|
||||
|
||||
|
||||
def run_genetic_lot_optimize(
|
||||
data: Path,
|
||||
mt5_path: Path,
|
||||
login: int,
|
||||
server: str,
|
||||
set_body: str,
|
||||
set_name: str,
|
||||
report: str,
|
||||
lot_key: str,
|
||||
*,
|
||||
test_symbol: str | None = None,
|
||||
optimization: int = 2,
|
||||
timeout_sec: int = 7200,
|
||||
) -> dict:
|
||||
write_tester_set(data, set_name, set_body)
|
||||
ini = data / f"{report}.ini"
|
||||
ini.write_text(
|
||||
build_ini(set_name, report, login, server, symbol=test_symbol, optimization=optimization),
|
||||
encoding="utf-8",
|
||||
)
|
||||
for ext in (".htm", ".html", ".xml"):
|
||||
p = data / f"{report}{ext}"
|
||||
if p.exists():
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
subprocess.run(["taskkill", "/IM", "terminal64.exe", "/F"], capture_output=True)
|
||||
subprocess.run(["taskkill", "/IM", "metatester64.exe", "/F"], capture_output=True)
|
||||
time.sleep(6)
|
||||
|
||||
t0 = time.time()
|
||||
subprocess.run([str(mt5_path / "terminal64.exe"), f"/config:{ini}"], timeout=timeout_sec)
|
||||
elapsed = round(time.time() - t0, 1)
|
||||
|
||||
opt = parse_optimization_xml(data, report, lot_key)
|
||||
opt["elapsed_sec"] = elapsed
|
||||
if not opt.get("ready"):
|
||||
metrics = parse_report(data, report)
|
||||
opt.update(metrics)
|
||||
return opt
|
||||
|
||||
|
||||
def write_tester_set(data: Path, set_name: str, body: str) -> Path:
|
||||
profiles = data / "MQL5" / "Profiles" / "Tester"
|
||||
profiles.mkdir(parents=True, exist_ok=True)
|
||||
path = profiles / set_name
|
||||
path.write_text(body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def build_ini(
|
||||
set_name: str,
|
||||
report: str,
|
||||
login: int,
|
||||
server: str,
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
optimization: int = 0,
|
||||
) -> str:
|
||||
sym = symbol or TEST_SYMBOL
|
||||
return f"""[Common]
|
||||
Login={login}
|
||||
Server={server}
|
||||
[Tester]
|
||||
Expert=main.ex5
|
||||
ExpertParameters={set_name}
|
||||
Symbol={sym}
|
||||
Period={TEST_PERIOD}
|
||||
Optimization={optimization}
|
||||
Model=1
|
||||
Dates=1
|
||||
FromDate={FROM_DATE}
|
||||
ToDate={TO_DATE}
|
||||
ForwardMode=0
|
||||
Deposit={DEPOSIT}
|
||||
Currency=USD
|
||||
Leverage={LEVERAGE}
|
||||
ExecutionMode=0
|
||||
Report={report}
|
||||
ReplaceReport=1
|
||||
ShutdownTerminal=1
|
||||
Visual=0
|
||||
"""
|
||||
|
||||
|
||||
def run_backtest(
|
||||
data: Path,
|
||||
mt5_path: Path,
|
||||
login: int,
|
||||
server: str,
|
||||
set_body: str,
|
||||
set_name: str,
|
||||
report: str,
|
||||
*,
|
||||
test_symbol: str | None = None,
|
||||
timeout_sec: int = 1800,
|
||||
) -> dict:
|
||||
write_tester_set(data, set_name, set_body)
|
||||
ini = data / f"{report}.ini"
|
||||
ini.write_text(build_ini(set_name, report, login, server, symbol=test_symbol), encoding="utf-8")
|
||||
for ext in (".htm", ".html"):
|
||||
p = data / f"{report}{ext}"
|
||||
if p.exists():
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
subprocess.run(["taskkill", "/IM", "terminal64.exe", "/F"], capture_output=True)
|
||||
subprocess.run(["taskkill", "/IM", "metatester64.exe", "/F"], capture_output=True)
|
||||
time.sleep(6)
|
||||
|
||||
t0 = time.time()
|
||||
subprocess.run([str(mt5_path / "terminal64.exe"), f"/config:{ini}"], timeout=timeout_sec)
|
||||
metrics = parse_report(data, report)
|
||||
metrics["elapsed_sec"] = round(time.time() - t0, 1)
|
||||
return metrics
|
||||
@@ -0,0 +1,117 @@
|
||||
"""United EA (main.mq5) strategy registry — defaults from 123.set + audit ranges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
|
||||
TF = {
|
||||
"M10": mt5.TIMEFRAME_M10,
|
||||
"M15": mt5.TIMEFRAME_M15,
|
||||
"M20": mt5.TIMEFRAME_M20,
|
||||
"M30": mt5.TIMEFRAME_M30,
|
||||
"H1": mt5.TIMEFRAME_H1,
|
||||
"H4": mt5.TIMEFRAME_H4,
|
||||
}
|
||||
|
||||
# Strategies enabled in 123.set, ordered for sequential audit.
|
||||
UNITED_STRATEGIES: list[dict] = [
|
||||
{"id": "united_darvas", "engine": "darvas", "symbol": "XAUUSD", "tf": "M15", "lot": 0.05,
|
||||
"enable_key": "EnableDarvasBox", "lot_key": "LOT_DB_DarvasBox",
|
||||
"defaults": {"box_period": 24, "box_deviation": 90000, "ma_period": 30,
|
||||
"trend_threshold": 1.2, "volume_threshold": 0,
|
||||
"stop_loss_pts": 300, "take_profit_pts": 950},
|
||||
"opt": {"box_period": (12, 48, 4), "box_deviation": (40000, 150000, 5000),
|
||||
"trend_threshold": (0.3, 5.0, 0.3), "stop_loss_pts": (250, 900, 50),
|
||||
"take_profit_pts": (350, 1200, 50), "ma_period": (30, 120, 15)}},
|
||||
{"id": "united_rsi_cross", "engine": "rsi_crossover", "symbol": "XAUUSD", "tf": "M15", "lot": 0.06,
|
||||
"enable_key": "EnableRSICrossOverReversal", "lot_key": "LOT_RC_RSICrossOver",
|
||||
"defaults": {"rsi_period": 19, "overbought_level": 85, "oversold_level": 25, "ema_period": 140,
|
||||
"ema_slope_threshold": 105, "ema_distance_threshold": 350, "exit_buy_rsi": 86,
|
||||
"exit_sell_rsi": 10, "trailing_stop_pts": 295, "cooldown_seconds": 120,
|
||||
"use_trend_strength_filter": True, "entry_rsi_buy_spread": 0, "entry_rsi_sell_spread": 0,
|
||||
"tuesday": True, "wednesday": True, "thursday": True,
|
||||
"trading_hour_one_begin": 0, "trading_hour_one_end": 22,
|
||||
"trading_hour_two_begin": 6, "trading_hour_two_end": 19},
|
||||
"opt": {"overbought_level": (70, 92, 3), "oversold_level": (15, 40, 3),
|
||||
"ema_distance_threshold": (200, 600, 50), "cooldown_seconds": (60, 300, 30)}},
|
||||
{"id": "united_rsi_scalp_appl", "engine": "rsi_scalp", "symbol": "AAPL", "tf": "M15", "lot": 15.0,
|
||||
"enable_key": "EnableRSIScalpingAPPL", "lot_key": "LOT_RS_APPL",
|
||||
"defaults": {"rsi_period": 8, "rsi_overbought": 64, "rsi_oversold": 30,
|
||||
"rsi_target_buy": 67, "rsi_target_sell": 2, "bars_to_wait": 5,
|
||||
"use_trailing": True, "trail_distance_pts": 70, "trail_activation_pts": 39, "tf": "M15",
|
||||
"skip_short_hour_after": 17, "use_reversal_escape": False,
|
||||
"use_rsi_against_exit": False, "max_adverse_atr": 2.5, "min_ob_depth": 0},
|
||||
"opt": {"rsi_period": (6, 14, 1), "rsi_overbought": (60, 75, 2), "rsi_oversold": (22, 35, 2),
|
||||
"rsi_target_buy": (60, 80, 3), "bars_to_wait": (5, 12, 1),
|
||||
"trail_distance_pts": (50, 120, 10), "skip_short_hour_after": (15, 20, 1)}},
|
||||
{"id": "united_rsi_scalp_adbe", "engine": "rsi_scalp", "symbol": "ADBE", "tf": "H1", "lot": 40.0,
|
||||
"enable_key": "EnableRSIScalpingADBE", "lot_key": "LOT_RS_ADBE",
|
||||
"defaults": {"rsi_period": 15, "rsi_overbought": 16, "rsi_oversold": 42,
|
||||
"rsi_target_buy": 67, "rsi_target_sell": 62, "bars_to_wait": 8,
|
||||
"use_trailing": True, "trail_distance_pts": 425, "trail_activation_pts": 18.5, "tf": "H1"},
|
||||
"opt": {"rsi_period": (6, 21, 1), "rsi_overbought": (50, 90, 3), "rsi_oversold": (10, 70, 3),
|
||||
"bars_to_wait": (1, 12, 1), "trail_distance_pts": (50, 500, 25)}},
|
||||
{"id": "united_rsi_scalp_btc", "engine": "rsi_scalp", "symbol": "BTCUSD", "tf": "H1", "lot": 0.03,
|
||||
"enable_key": "EnableRSIScalpingBTCUSD", "lot_key": "LOT_RS_BTCUSD",
|
||||
"defaults": {"rsi_period": 14, "rsi_overbought": 90, "rsi_oversold": 73,
|
||||
"rsi_target_buy": 88, "rsi_target_sell": 48, "bars_to_wait": 6,
|
||||
"use_trailing": True, "trail_distance_pts": 120, "trail_activation_pts": 0, "tf": "H1"},
|
||||
"opt": {"rsi_period": (6, 21, 1), "rsi_overbought": (50, 95, 3), "rsi_oversold": (20, 80, 3),
|
||||
"bars_to_wait": (1, 8, 1), "trail_distance_pts": (40, 300, 20)}},
|
||||
{"id": "united_rsi_scalp_nvda", "engine": "rsi_scalp", "symbol": "NVDA", "tf": "M15", "lot": 50.0,
|
||||
"enable_key": "EnableRSIScalpingNVDA", "lot_key": "LOT_RS_NVDA",
|
||||
"defaults": {"rsi_period": 8, "rsi_overbought": 36, "rsi_oversold": 38,
|
||||
"rsi_target_buy": 90, "rsi_target_sell": 70, "bars_to_wait": 5,
|
||||
"use_trailing": True, "trail_distance_pts": 375, "trail_activation_pts": 75, "tf": "M15"},
|
||||
"opt": {"rsi_period": (6, 21, 1), "bars_to_wait": (1, 8, 1), "trail_distance_pts": (50, 500, 25)}},
|
||||
{"id": "united_rsi_scalp_tsla", "engine": "rsi_scalp", "symbol": "TSLA", "tf": "H1", "lot": 5.0,
|
||||
"enable_key": "EnableRSIScalpingTSLA", "lot_key": "LOT_RS_TSLA",
|
||||
"defaults": {"rsi_period": 14, "rsi_overbought": 54, "rsi_oversold": 73,
|
||||
"rsi_target_buy": 87, "rsi_target_sell": 33, "bars_to_wait": 1,
|
||||
"use_trailing": True, "trail_distance_pts": 900, "trail_activation_pts": 950, "tf": "H1"},
|
||||
"opt": {"rsi_period": (6, 21, 1), "bars_to_wait": (1, 6, 1), "trail_distance_pts": (100, 1000, 50)}},
|
||||
{"id": "united_rsi_scalp_xau", "engine": "rsi_scalp", "symbol": "XAUUSD", "tf": "H1", "lot": 0.02,
|
||||
"enable_key": "EnableRSIScalpingXAUUSD", "lot_key": "LOT_RS_XAUUSD",
|
||||
"defaults": {"rsi_period": 14, "rsi_overbought": 71, "rsi_oversold": 57,
|
||||
"rsi_target_buy": 80, "rsi_target_sell": 57, "bars_to_wait": 1,
|
||||
"use_trailing": True, "trail_distance_pts": 71, "trail_activation_pts": 41, "tf": "H1"},
|
||||
"opt": {"rsi_period": (6, 21, 1), "rsi_overbought": (55, 85, 3), "rsi_oversold": (40, 75, 3),
|
||||
"bars_to_wait": (1, 4, 1), "trail_distance_pts": (20, 150, 10)}},
|
||||
{"id": "united_rsi_asian_eur", "engine": "rsi_asian", "symbol": "EURUSD", "tf": "M15", "lot": 0.04,
|
||||
"enable_key": "EnableRSIReversalAsianEURUSD", "lot_key": "LOT_RRA_EURUSD",
|
||||
"defaults": {"rsi_period": 28, "overbought_level": 60, "oversold_level": 8,
|
||||
"asian_session_start": 0, "asian_session_end": 8, "use_rsi_exit": True, "rsi_exit_level": 55},
|
||||
"opt": {"overbought_level": (50, 80, 5), "oversold_level": (5, 40, 5)}},
|
||||
{"id": "united_rsi_asian_aud", "engine": "rsi_asian", "symbol": "AUDUSD", "tf": "M15", "lot": 0.03,
|
||||
"enable_key": "EnableRSIReversalAsianAUDUSD", "lot_key": "LOT_RRA_AUDUSD",
|
||||
"defaults": {"rsi_period": 28, "overbought_level": 68, "oversold_level": 30,
|
||||
"asian_session_start": 0, "asian_session_end": 8, "use_rsi_exit": True, "rsi_exit_level": 48,
|
||||
"close_outside_session": True},
|
||||
"opt": {"overbought_level": (55, 80, 5), "oversold_level": (15, 45, 5)}},
|
||||
{"id": "united_st_xau", "engine": "simple_trendline", "symbol": "XAUUSD", "tf": "H1", "lot": 0.03,
|
||||
"enable_key": "EnableSimpleTrendlineXAUUSD", "lot_key": "LOT_ST_XAUUSD",
|
||||
"defaults": {"signal_tf": "H1", "higher_tf": "M10", "ma_period": 65, "ma_method": "ema",
|
||||
"htf_bars_to_scan": 500, "touch_tolerance_pts": 220, "break_buffer_pts": 110},
|
||||
"opt": {"ma_period": (40, 120, 10), "touch_tolerance_pts": (100, 350, 25),
|
||||
"break_buffer_pts": (50, 200, 15)}},
|
||||
{"id": "united_st_ger40", "engine": "simple_trendline", "symbol": "GER40", "tf": "M15", "lot": 0.04,
|
||||
"enable_key": "EnableSimpleTrendlineGER40", "lot_key": "LOT_ST_GER40",
|
||||
"defaults": {"signal_tf": "M15", "higher_tf": "M15", "ma_period": 65, "ma_method": "lwma",
|
||||
"htf_bars_to_scan": 1200, "touch_tolerance_pts": 100, "break_buffer_pts": 80},
|
||||
"opt": {"ma_period": (40, 120, 10), "touch_tolerance_pts": (50, 200, 15)}},
|
||||
{"id": "united_rsi_secret", "engine": "rsi_secret", "symbol": "XAUUSD", "tf": "M30", "lot": 0.07,
|
||||
"enable_key": "EnableRSISecretSauce", "lot_key": "LOT_RSS_SecretSauce",
|
||||
"defaults": {"rsi_period": 16, "rsi_overbought": 72.5, "rsi_oversold": 32.5,
|
||||
"stop_loss_atr": 2.75, "take_profit_atr": 5.0, "min_bars_between_trades": 7},
|
||||
"opt": {"rsi_overbought": (60, 80, 2), "rsi_oversold": (25, 45, 2), "stop_loss_atr": (1.5, 4, 0.5)}},
|
||||
{"id": "united_usdjpy", "engine": "usdjpy_buster", "symbol": "USDJPY", "tf": "M20", "lot": 0.09,
|
||||
"enable_key": "EnableUSDJPYBuster", "lot_key": "LOT_UB_USDJPY",
|
||||
"defaults": {"range_start_hour": 3, "range_end_hour": 6, "close_hour": 18,
|
||||
"min_range_pts": 15, "order_buffer_pts": 4.75, "first_trade_only": False,
|
||||
"allow_long": True, "allow_short": True, "use_take_profit": False},
|
||||
"opt": {"min_range_pts": (8, 40, 4), "order_buffer_pts": (2, 12, 1)}},
|
||||
]
|
||||
|
||||
PERIODS = {
|
||||
"2021-2026": ("2021-01-01", "2026-06-01"),
|
||||
}
|
||||
@@ -10,13 +10,14 @@ import pandas as pd
|
||||
|
||||
|
||||
def calculate_rsi(prices: pd.Series, period: int = 14) -> pd.Series:
|
||||
"""Calculate RSI indicator."""
|
||||
"""Calculate RSI with Wilder smoothing (matches MT5 iRSI)."""
|
||||
delta = prices.diff()
|
||||
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
|
||||
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
|
||||
rs = gain / loss
|
||||
rsi = 100 - (100 / (1 + rs))
|
||||
return rsi
|
||||
gain = delta.clip(lower=0)
|
||||
loss = (-delta).clip(lower=0)
|
||||
avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean()
|
||||
avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean()
|
||||
rs = avg_gain / avg_loss.replace(0, np.nan)
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
|
||||
def calculate_ema(prices: pd.Series, period: int = 50) -> pd.Series:
|
||||
@@ -30,13 +31,35 @@ def calculate_sma(prices: pd.Series, period: int = 50) -> pd.Series:
|
||||
|
||||
|
||||
def calculate_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||
"""Calculate ATR indicator."""
|
||||
"""Calculate ATR with Wilder smoothing (matches MT5 iATR)."""
|
||||
high_low = df['high'] - df['low']
|
||||
high_close = np.abs(df['high'] - df['close'].shift())
|
||||
low_close = np.abs(df['low'] - df['close'].shift())
|
||||
tr = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1)
|
||||
atr = tr.rolling(window=period).mean()
|
||||
return atr
|
||||
return tr.ewm(alpha=1 / period, adjust=False).mean()
|
||||
|
||||
|
||||
def calculate_adx(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||
"""Calculate ADX indicator (Wilder smoothing)."""
|
||||
return calculate_dmi(df, period)["adx"]
|
||||
|
||||
|
||||
def calculate_dmi(df: pd.DataFrame, period: int = 14) -> pd.DataFrame:
|
||||
"""Calculate +DI, -DI, and ADX."""
|
||||
high = df["high"]
|
||||
low = df["low"]
|
||||
close = df["close"]
|
||||
up = high.diff()
|
||||
down = -low.diff()
|
||||
plus_dm = up.where((up > down) & (up > 0), 0.0)
|
||||
minus_dm = down.where((down > up) & (down > 0), 0.0)
|
||||
tr = pd.concat([high - low, (high - close.shift()).abs(), (low - close.shift()).abs()], axis=1).max(axis=1)
|
||||
atr = tr.ewm(alpha=1 / period, adjust=False).mean()
|
||||
plus_di = 100 * (plus_dm.ewm(alpha=1 / period, adjust=False).mean() / atr.replace(0, np.nan))
|
||||
minus_di = 100 * (minus_dm.ewm(alpha=1 / period, adjust=False).mean() / atr.replace(0, np.nan))
|
||||
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan)
|
||||
adx = dx.ewm(alpha=1 / period, adjust=False).mean()
|
||||
return pd.DataFrame({"plus_di": plus_di, "minus_di": minus_di, "adx": adx})
|
||||
|
||||
|
||||
def calculate_macd(prices: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> pd.DataFrame:
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
Bar-based RSI scalping backtest — conservative fills, costs, no same-bar RSI lookahead.
|
||||
|
||||
Mirrors RsiScalpingRobot.mqh with:
|
||||
- entries on bar open after RSI cross on prior closed bars
|
||||
- exits evaluated on prior closed bar RSI
|
||||
- trailing updated on bar close; stop checked against bar range
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from indicator_utils import calculate_rsi
|
||||
|
||||
|
||||
@dataclass
|
||||
class RsiScalpParams:
|
||||
rsi_period: int = 14
|
||||
rsi_overbought: float = 71.0
|
||||
rsi_oversold: float = 57.0
|
||||
rsi_target_buy: float = 80.0
|
||||
rsi_target_sell: float = 57.0
|
||||
bars_to_wait: int = 1
|
||||
use_trailing: bool = True
|
||||
trail_distance_pts: float = 71.0
|
||||
trail_activation_pts: float = 41.0
|
||||
lot_size: float = 0.1
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, Any]) -> "RsiScalpParams":
|
||||
return cls(**{k: d[k] for k in cls.__dataclass_fields__ if k in d})
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostModel:
|
||||
spread_points: float = 0.0
|
||||
slippage_points: float = 3.0
|
||||
commission_per_lot: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def from_symbol(cls, symbol: str, slippage_points: float = 3.0, commission_per_lot: float = 0.0) -> "CostModel":
|
||||
info = mt5.symbol_info(symbol)
|
||||
spread = float(info.spread) if info else 0.0
|
||||
return cls(spread_points=spread, slippage_points=slippage_points, commission_per_lot=commission_per_lot)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestResult:
|
||||
net_profit: float
|
||||
total_trades: int
|
||||
win_rate: float
|
||||
profit_factor: float
|
||||
max_drawdown_pct: float
|
||||
total_costs: float
|
||||
score: float
|
||||
params: RsiScalpParams
|
||||
gross_profit: float = 0.0
|
||||
gross_loss: float = 0.0
|
||||
|
||||
|
||||
def _calc_profit(symbol: str, order_type: int, volume: float, open_price: float, close_price: float) -> float:
|
||||
p = mt5.order_calc_profit(order_type, symbol, volume, open_price, close_price)
|
||||
return float(p) if p is not None else 0.0
|
||||
|
||||
|
||||
def _half_spread_price(point: float, spread_points: float) -> float:
|
||||
return (spread_points * point) / 2.0
|
||||
|
||||
|
||||
def _fill_buy(open_price: float, point: float, costs: CostModel, entry: bool) -> float:
|
||||
slip = costs.slippage_points * point
|
||||
hs = _half_spread_price(point, costs.spread_points)
|
||||
return open_price + hs + slip if entry else open_price - hs - slip
|
||||
|
||||
|
||||
def _fill_sell(open_price: float, point: float, costs: CostModel, entry: bool) -> float:
|
||||
slip = costs.slippage_points * point
|
||||
hs = _half_spread_price(point, costs.spread_points)
|
||||
return open_price - hs - slip if entry else open_price + hs + slip
|
||||
|
||||
|
||||
def backtest_rsi_scalping(
|
||||
df: pd.DataFrame,
|
||||
symbol: str,
|
||||
params: RsiScalpParams,
|
||||
initial_balance: float = 10_000.0,
|
||||
point: float | None = None,
|
||||
costs: CostModel | None = None,
|
||||
) -> BacktestResult:
|
||||
info = mt5.symbol_info(symbol)
|
||||
if point is None:
|
||||
point = float(info.point) if info else 0.01
|
||||
if costs is None:
|
||||
costs = CostModel.from_symbol(symbol)
|
||||
|
||||
# RSI on close; decisions use index i-1 (last fully closed bar at bar i open)
|
||||
rsi_full = calculate_rsi(df["close"], params.rsi_period).to_numpy()
|
||||
times = df.index.to_numpy()
|
||||
opens = df["open"].to_numpy()
|
||||
highs = df["high"].to_numpy()
|
||||
lows = df["low"].to_numpy()
|
||||
closes = df["close"].to_numpy()
|
||||
|
||||
balance = initial_balance
|
||||
peak = initial_balance
|
||||
max_dd = 0.0
|
||||
total_costs = 0.0
|
||||
|
||||
position: dict[str, Any] | None = None
|
||||
rsi_against = False
|
||||
bars_against = 0
|
||||
|
||||
gross_profit = 0.0
|
||||
gross_loss = 0.0
|
||||
wins = 0
|
||||
losses = 0
|
||||
trades = 0
|
||||
|
||||
trail_dist = params.trail_distance_pts * point
|
||||
trail_act = (params.trail_activation_pts if params.trail_activation_pts > 0 else params.trail_distance_pts) * point
|
||||
|
||||
def _update_dd() -> None:
|
||||
nonlocal peak, max_dd
|
||||
if balance > peak:
|
||||
peak = balance
|
||||
dd = (peak - balance) / peak if peak > 0 else 0.0
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
|
||||
def close_at(exit_mid: float) -> None:
|
||||
nonlocal balance, gross_profit, gross_loss, wins, losses, trades, position, total_costs
|
||||
if position is None:
|
||||
return
|
||||
order_type = mt5.ORDER_TYPE_BUY if position["type"] == "BUY" else mt5.ORDER_TYPE_SELL
|
||||
if position["type"] == "BUY":
|
||||
exit_price = _fill_buy(exit_mid, point, costs, entry=False)
|
||||
else:
|
||||
exit_price = _fill_sell(exit_mid, point, costs, entry=False)
|
||||
|
||||
commission = costs.commission_per_lot * position["volume"] * 2.0
|
||||
profit = _calc_profit(symbol, order_type, position["volume"], position["open_price"], exit_price)
|
||||
profit -= commission
|
||||
total_costs += commission + (costs.slippage_points * point * position["volume"] * 100000 * 0.0)
|
||||
|
||||
balance += profit
|
||||
trades += 1
|
||||
if profit >= 0:
|
||||
wins += 1
|
||||
gross_profit += profit
|
||||
else:
|
||||
losses += 1
|
||||
gross_loss += abs(profit)
|
||||
_update_dd()
|
||||
position = None
|
||||
|
||||
def apply_trailing(bar_close: float, bar_high: float, bar_low: float) -> None:
|
||||
if position is None or not params.use_trailing or trail_dist <= 0:
|
||||
return
|
||||
if position["type"] == "BUY":
|
||||
bid = bar_close
|
||||
if bid - position["open_price"] <= trail_act:
|
||||
return
|
||||
new_sl = bid - trail_dist
|
||||
if new_sl > position.get("sl", 0.0):
|
||||
position["sl"] = new_sl
|
||||
if position.get("sl") and bar_low <= position["sl"]:
|
||||
close_at(position["sl"])
|
||||
else:
|
||||
ask = bar_close
|
||||
if position["open_price"] - ask <= trail_act:
|
||||
return
|
||||
new_sl = ask + trail_dist
|
||||
if position.get("sl", 0.0) == 0.0 or new_sl < position["sl"]:
|
||||
position["sl"] = new_sl
|
||||
if position.get("sl") and bar_high >= position["sl"]:
|
||||
close_at(position["sl"])
|
||||
|
||||
start = max(params.rsi_period + 3, 3)
|
||||
for i in range(start, len(df)):
|
||||
# closed-bar RSI (no lookahead): signal bar is i-1
|
||||
rsi_sig = rsi_full[i - 1]
|
||||
rsi_prev = rsi_full[i - 2]
|
||||
rsi_two = rsi_full[i - 3]
|
||||
if np.isnan(rsi_sig) or np.isnan(rsi_prev) or np.isnan(rsi_two):
|
||||
continue
|
||||
|
||||
if position is not None:
|
||||
apply_trailing(closes[i], highs[i], lows[i])
|
||||
if position is None:
|
||||
rsi_against = False
|
||||
bars_against = 0
|
||||
continue
|
||||
|
||||
if position["type"] == "BUY":
|
||||
if rsi_sig < params.rsi_oversold:
|
||||
if not rsi_against:
|
||||
rsi_against = True
|
||||
bars_against = 1
|
||||
else:
|
||||
bars_against += 1
|
||||
if bars_against >= params.bars_to_wait:
|
||||
close_at(opens[i])
|
||||
else:
|
||||
if rsi_against:
|
||||
rsi_against = False
|
||||
bars_against = 0
|
||||
if rsi_sig >= params.rsi_target_buy:
|
||||
close_at(opens[i])
|
||||
else:
|
||||
if rsi_sig > params.rsi_overbought:
|
||||
if not rsi_against:
|
||||
rsi_against = True
|
||||
bars_against = 1
|
||||
else:
|
||||
bars_against += 1
|
||||
if bars_against >= params.bars_to_wait:
|
||||
close_at(opens[i])
|
||||
else:
|
||||
if rsi_against:
|
||||
rsi_against = False
|
||||
bars_against = 0
|
||||
if rsi_sig <= params.rsi_target_sell:
|
||||
close_at(opens[i])
|
||||
|
||||
if position is not None:
|
||||
continue
|
||||
|
||||
# entry at bar open[i] from RSI cross on bars i-2 / i-3
|
||||
if rsi_two <= params.rsi_oversold and rsi_prev > params.rsi_oversold:
|
||||
entry = _fill_buy(opens[i], point, costs, entry=True)
|
||||
position = {"type": "BUY", "volume": params.lot_size, "open_price": entry, "open_time": times[i], "sl": 0.0}
|
||||
rsi_against = False
|
||||
bars_against = 0
|
||||
elif rsi_two >= params.rsi_overbought and rsi_prev < params.rsi_overbought:
|
||||
entry = _fill_sell(opens[i], point, costs, entry=True)
|
||||
position = {"type": "SELL", "volume": params.lot_size, "open_price": entry, "open_time": times[i], "sl": 0.0}
|
||||
rsi_against = False
|
||||
bars_against = 0
|
||||
|
||||
if position is not None:
|
||||
close_at(closes[-1])
|
||||
|
||||
net_profit = balance - initial_balance
|
||||
win_rate = (wins / trades * 100.0) if trades else 0.0
|
||||
pf = (gross_profit / gross_loss) if gross_loss > 0 else (999.0 if gross_profit > 0 else 0.0)
|
||||
|
||||
if trades < 20:
|
||||
score = net_profit - 10_000.0
|
||||
else:
|
||||
score = net_profit * (1.0 - min(max_dd, 0.5))
|
||||
|
||||
return BacktestResult(
|
||||
net_profit=net_profit,
|
||||
total_trades=trades,
|
||||
win_rate=win_rate,
|
||||
profit_factor=pf,
|
||||
max_drawdown_pct=max_dd * 100.0,
|
||||
total_costs=total_costs,
|
||||
score=score,
|
||||
params=params,
|
||||
gross_profit=gross_profit,
|
||||
gross_loss=gross_loss,
|
||||
)
|
||||
|
||||
|
||||
def resolve_symbol(requested: str) -> str:
|
||||
key = requested.split("|")[0].strip()
|
||||
if not key:
|
||||
return requested
|
||||
if mt5.symbol_info(key) is not None:
|
||||
mt5.symbol_select(key, True)
|
||||
return key
|
||||
for suffix in (".NAS", ".NYSE", ".NYS", ".US"):
|
||||
cand = key + suffix
|
||||
if mt5.symbol_info(cand) is not None:
|
||||
mt5.symbol_select(cand, True)
|
||||
return cand
|
||||
for sym in mt5.symbols_get() or []:
|
||||
name = sym.name
|
||||
if name.startswith(key + "."):
|
||||
mt5.symbol_select(name, True)
|
||||
return name
|
||||
return key
|
||||
|
||||
|
||||
def load_rates(symbol: str, timeframe: int, start, end) -> pd.DataFrame:
|
||||
symbol = resolve_symbol(symbol)
|
||||
if not mt5.symbol_select(symbol, True):
|
||||
raise RuntimeError(f"Cannot select {symbol}: {mt5.last_error()}")
|
||||
rates = mt5.copy_rates_range(symbol, timeframe, start, end)
|
||||
if rates is None or len(rates) == 0:
|
||||
raise RuntimeError(f"No rates for {symbol}: {mt5.last_error()}")
|
||||
out = pd.DataFrame(rates)
|
||||
out["time"] = pd.to_datetime(out["time"], unit="s")
|
||||
out.set_index("time", inplace=True)
|
||||
return out
|
||||
|
||||
|
||||
def split_walk_forward(df: pd.DataFrame, train_ratio: float = 0.6) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
cut = int(len(df) * train_ratio)
|
||||
if cut < 100 or len(df) - cut < 100:
|
||||
raise ValueError("Not enough bars for walk-forward split")
|
||||
return df.iloc[:cut].copy(), df.iloc[cut:].copy()
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Walk-forward random-search optimizer for RSI scalping.
|
||||
|
||||
Optimizes on in-sample (train), ranks by out-of-sample (validation) score.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
import pandas as pd
|
||||
|
||||
from rsi_scalping_backtest import (
|
||||
CostModel,
|
||||
RsiScalpParams,
|
||||
backtest_rsi_scalping,
|
||||
load_rates,
|
||||
split_walk_forward,
|
||||
)
|
||||
from set_parser import SetParam, parse_set_file
|
||||
|
||||
TF_MAP = {
|
||||
"M1": mt5.TIMEFRAME_M1,
|
||||
"M5": mt5.TIMEFRAME_M5,
|
||||
"M10": mt5.TIMEFRAME_M10,
|
||||
"M15": mt5.TIMEFRAME_M15,
|
||||
"M30": mt5.TIMEFRAME_M30,
|
||||
"H1": mt5.TIMEFRAME_H1,
|
||||
"H4": mt5.TIMEFRAME_H4,
|
||||
"D1": mt5.TIMEFRAME_D1,
|
||||
}
|
||||
|
||||
SET_TO_PARAM = {
|
||||
"RSI_Period": "rsi_period",
|
||||
"RSI_Overbought": "rsi_overbought",
|
||||
"RSI_Oversold": "rsi_oversold",
|
||||
"RSI_Target_Buy": "rsi_target_buy",
|
||||
"RSI_Target_Sell": "rsi_target_sell",
|
||||
"BarsToWait": "bars_to_wait",
|
||||
"UseTrailingStop": "use_trailing",
|
||||
"TrailingStopDistancePoints": "trail_distance_pts",
|
||||
"TrailingActivationPoints": "trail_activation_pts",
|
||||
}
|
||||
|
||||
|
||||
def _sample_value(p: SetParam, rng: random.Random) -> Any:
|
||||
if not p.optimize:
|
||||
return p.value
|
||||
if isinstance(p.start, bool):
|
||||
return rng.choice([p.start, p.stop])
|
||||
if isinstance(p.start, int) and isinstance(p.stop, int):
|
||||
step = int(p.step) if int(p.step) != 0 else 1
|
||||
vals = list(range(int(p.start), int(p.stop) + 1, step))
|
||||
return rng.choice(vals) if vals else p.value
|
||||
step = float(p.step) if float(p.step) != 0 else 1.0
|
||||
start, stop = float(p.start), float(p.stop)
|
||||
n = int((stop - start) / step) + 1
|
||||
idx = rng.randint(0, max(n - 1, 0))
|
||||
return round(start + idx * step, 4)
|
||||
|
||||
|
||||
def sample_params(set_params: dict[str, SetParam], rng: random.Random, defaults: dict, fixed_lot: float) -> RsiScalpParams:
|
||||
raw = dict(defaults)
|
||||
for set_name, field in SET_TO_PARAM.items():
|
||||
if set_name in set_params:
|
||||
raw[field] = _sample_value(set_params[set_name], rng)
|
||||
raw["lot_size"] = fixed_lot
|
||||
return RsiScalpParams.from_dict(raw)
|
||||
|
||||
|
||||
def _result_dict(r, label: str) -> dict:
|
||||
return {
|
||||
"label": label,
|
||||
"net_profit": r.net_profit,
|
||||
"total_trades": r.total_trades,
|
||||
"win_rate": r.win_rate,
|
||||
"profit_factor": r.profit_factor,
|
||||
"max_drawdown_pct": r.max_drawdown_pct,
|
||||
"total_costs": r.total_costs,
|
||||
"score": r.score,
|
||||
"params": r.params.__dict__,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Walk-forward RSI scalping optimizer")
|
||||
parser.add_argument("--symbol", default="XAUUSD")
|
||||
parser.add_argument("--timeframe", default="H1", choices=TF_MAP.keys())
|
||||
parser.add_argument("--set", required=True)
|
||||
parser.add_argument("--trials", type=int, default=800)
|
||||
parser.add_argument("--days", type=int, default=730)
|
||||
parser.add_argument("--balance", type=float, default=10000.0)
|
||||
parser.add_argument("--lot", type=float, default=0.1)
|
||||
parser.add_argument("--train-ratio", type=float, default=0.6)
|
||||
parser.add_argument("--slippage", type=float, default=3.0)
|
||||
parser.add_argument("--commission", type=float, default=0.0)
|
||||
parser.add_argument("--seed", type=int, default=42)
|
||||
parser.add_argument("--out", default="optimization_results")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not mt5.initialize():
|
||||
raise SystemExit(f"MT5 init failed: {mt5.last_error()}")
|
||||
|
||||
try:
|
||||
end = datetime.now()
|
||||
start = end - timedelta(days=args.days)
|
||||
tf = TF_MAP[args.timeframe]
|
||||
df_all = load_rates(args.symbol, tf, start, end)
|
||||
train_df, test_df = split_walk_forward(df_all, args.train_ratio)
|
||||
costs = CostModel.from_symbol(args.symbol, slippage_points=args.slippage, commission_per_lot=args.commission)
|
||||
|
||||
info = mt5.symbol_info(args.symbol)
|
||||
spread = info.spread if info else 0
|
||||
print(f"Symbol {args.symbol} spread={spread} pts slippage={args.slippage} commission/lot={args.commission}")
|
||||
print(f"All: {len(df_all)} bars train: {len(train_df)} ({train_df.index[0]} -> {train_df.index[-1]})")
|
||||
print(f"Test: {len(test_df)} bars ({test_df.index[0]} -> {test_df.index[-1]})")
|
||||
|
||||
set_params = parse_set_file(args.set)
|
||||
defaults = {
|
||||
"rsi_period": 14,
|
||||
"rsi_overbought": 71.0,
|
||||
"rsi_oversold": 57.0,
|
||||
"rsi_target_buy": 80.0,
|
||||
"rsi_target_sell": 57.0,
|
||||
"bars_to_wait": 1,
|
||||
"use_trailing": True,
|
||||
"trail_distance_pts": 71.0,
|
||||
"trail_activation_pts": 41.0,
|
||||
}
|
||||
|
||||
baseline_params = RsiScalpParams.from_dict({**defaults, "lot_size": args.lot})
|
||||
baseline_train = backtest_rsi_scalping(train_df, args.symbol, baseline_params, args.balance, costs=costs)
|
||||
baseline_test = backtest_rsi_scalping(test_df, args.symbol, baseline_params, args.balance, costs=costs)
|
||||
baseline_full = backtest_rsi_scalping(df_all, args.symbol, baseline_params, args.balance, costs=costs)
|
||||
|
||||
print("\n--- BASELINE (current SuperEA XAUUSD trailing defaults) ---")
|
||||
print(f" train net=${baseline_train.net_profit:.2f} trades={baseline_train.total_trades} dd={baseline_train.max_drawdown_pct:.1f}%")
|
||||
print(f" test net=${baseline_test.net_profit:.2f} trades={baseline_test.total_trades} dd={baseline_test.max_drawdown_pct:.1f}%")
|
||||
print(f" full net=${baseline_full.net_profit:.2f} trades={baseline_full.total_trades} dd={baseline_full.max_drawdown_pct:.1f}%")
|
||||
|
||||
rng = random.Random(args.seed)
|
||||
rows = []
|
||||
best_oos = None
|
||||
best_oos_score = float("-inf")
|
||||
|
||||
for n in range(1, args.trials + 1):
|
||||
params = sample_params(set_params, rng, defaults, args.lot)
|
||||
train_r = backtest_rsi_scalping(train_df, args.symbol, params, args.balance, costs=costs)
|
||||
test_r = backtest_rsi_scalping(test_df, args.symbol, params, args.balance, costs=costs)
|
||||
full_r = backtest_rsi_scalping(df_all, args.symbol, params, args.balance, costs=costs)
|
||||
|
||||
row = {
|
||||
"trial": n,
|
||||
"oos_score": test_r.score,
|
||||
"train_net": train_r.net_profit,
|
||||
"test_net": test_r.net_profit,
|
||||
"full_net": full_r.net_profit,
|
||||
"train_trades": train_r.total_trades,
|
||||
"test_trades": test_r.total_trades,
|
||||
"test_pf": test_r.profit_factor,
|
||||
"test_dd_pct": test_r.max_drawdown_pct,
|
||||
"test_win_rate": test_r.win_rate,
|
||||
**params.__dict__,
|
||||
}
|
||||
rows.append(row)
|
||||
|
||||
if test_r.total_trades >= 15 and test_r.score > best_oos_score:
|
||||
best_oos_score = test_r.score
|
||||
best_oos = (params, train_r, test_r, full_r)
|
||||
|
||||
if n % 200 == 0 and best_oos:
|
||||
_, _, br_test, _ = best_oos
|
||||
print(f" trial {n}/{args.trials} best OOS net=${br_test.net_profit:.2f} score={best_oos_score:.2f}")
|
||||
|
||||
if best_oos is None:
|
||||
raise SystemExit("No valid OOS candidate (need >=15 test trades)")
|
||||
|
||||
best_params, best_train, best_test, best_full = best_oos
|
||||
|
||||
out_dir = Path(args.out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
results_df = pd.DataFrame(rows).sort_values("oos_score", ascending=False)
|
||||
tag = f"{args.symbol}_{args.timeframe}_v2"
|
||||
csv_path = out_dir / f"{tag}_rsi_scalp_opt.csv"
|
||||
results_df.to_csv(csv_path, index=False)
|
||||
|
||||
report = {
|
||||
"version": "v2-conservative-walkforward",
|
||||
"symbol": args.symbol,
|
||||
"timeframe": args.timeframe,
|
||||
"costs": {"spread_pts": spread, "slippage_pts": args.slippage, "commission_per_lot": args.commission},
|
||||
"bars": {"all": len(df_all), "train": len(train_df), "test": len(test_df)},
|
||||
"periods": {
|
||||
"all": [str(df_all.index[0]), str(df_all.index[-1])],
|
||||
"train": [str(train_df.index[0]), str(train_df.index[-1])],
|
||||
"test": [str(test_df.index[0]), str(test_df.index[-1])],
|
||||
},
|
||||
"trials": args.trials,
|
||||
"baseline": {
|
||||
"train": _result_dict(baseline_train, "train"),
|
||||
"test": _result_dict(baseline_test, "test"),
|
||||
"full": _result_dict(baseline_full, "full"),
|
||||
},
|
||||
"best_by_oos": {
|
||||
"train": _result_dict(best_train, "train"),
|
||||
"test": _result_dict(best_test, "test"),
|
||||
"full": _result_dict(best_full, "full"),
|
||||
},
|
||||
"top10_oos": results_df.head(10).to_dict(orient="records"),
|
||||
}
|
||||
json_path = out_dir / f"{tag}_rsi_scalp_best.json"
|
||||
json_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
|
||||
print("\n=== BEST BY OUT-OF-SAMPLE (validation) ===")
|
||||
for k, v in best_params.__dict__.items():
|
||||
print(f" {k}: {v}")
|
||||
print(f" TRAIN net=${best_train.net_profit:.2f} trades={best_train.total_trades} dd={best_train.max_drawdown_pct:.1f}%")
|
||||
print(f" TEST net=${best_test.net_profit:.2f} trades={best_test.total_trades} pf={best_test.profit_factor:.2f} dd={best_test.max_drawdown_pct:.1f}%")
|
||||
print(f" FULL net=${best_full.net_profit:.2f} trades={best_full.total_trades} dd={best_full.max_drawdown_pct:.1f}%")
|
||||
print(f"\nSaved: {csv_path}")
|
||||
print(f"Saved: {json_path}")
|
||||
finally:
|
||||
mt5.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Parse MetaTrader 5 .set optimization files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SetParam:
|
||||
name: str
|
||||
value: Any
|
||||
start: Any
|
||||
step: Any
|
||||
stop: Any
|
||||
optimize: bool
|
||||
|
||||
|
||||
def _cast(raw: str) -> Any:
|
||||
low = raw.strip().lower()
|
||||
if low == "true":
|
||||
return True
|
||||
if low == "false":
|
||||
return False
|
||||
if "." in raw:
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return raw
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return raw
|
||||
|
||||
|
||||
def parse_set_file(path: str | Path) -> dict[str, SetParam]:
|
||||
params: dict[str, SetParam] = {}
|
||||
for line in Path(path).read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith(";"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
name, rest = line.split("=", 1)
|
||||
parts = rest.split("||")
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
value, start, step, stop, opt = parts[0], parts[1], parts[2], parts[3], parts[4]
|
||||
params[name] = SetParam(
|
||||
name=name,
|
||||
value=_cast(value),
|
||||
start=_cast(start),
|
||||
step=_cast(step),
|
||||
stop=_cast(stop),
|
||||
optimize=opt.strip().upper() == "Y",
|
||||
)
|
||||
return params
|
||||
@@ -0,0 +1,11 @@
|
||||
; RSIScalping XAUUSD trailing — Python optimizer ranges
|
||||
RSI_Period=14||8||1||21||Y
|
||||
RSI_Overbought=71||55.0||2.0||85.0||Y
|
||||
RSI_Oversold=57||40.0||2.0||70.0||Y
|
||||
RSI_Target_Buy=80||65.0||2.0||95.0||Y
|
||||
RSI_Target_Sell=57||40.0||2.0||70.0||Y
|
||||
BarsToWait=1||1||1||8||Y
|
||||
LotSize=0.1||0.1||0.1||0.1||N
|
||||
UseTrailingStop=true||false||0||true||Y
|
||||
TrailingStopDistancePoints=71||20.0||5.0||200.0||Y
|
||||
TrailingActivationPoints=41||0.0||5.0||120.0||Y
|
||||
Reference in New Issue
Block a user