605faf5310
Add cluster audit pipeline, united EA updates, brochure generators, and publication hygiene (gitignore, MT5 path desensitization, pre-upload scan). Remove tracked reports, models, and binary artifacts from the repo. Co-authored-by: Cursor <cursoragent@cursor.com>
883 lines
34 KiB
Python
883 lines
34 KiB
Python
"""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,
|
|
}
|