ответвлён от animatedread/Warrior_EA
Recovered live config (z20 <= -1.5, exit SMA20 / 10 bars, 3xATR) plus a Garman-Klass vol-regime gate. Expectancy is monotone in the vol regime in IS, OOS and full sample, 4/4 indices; the gate reverses on USDJPY/XAUUSD. D1 2008-2026 survives 2008/2020/2022 (maxDD 2.8%, ret/DD 7.74); the gate halves trades, so it belongs on H4, never D1. reconcile.py matches the EA to the backtest trade by trade; combine_charts.py rebuilds the account curve from per-chart tester runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 строки
7,2 КиБ
Python
186 строки
7,2 КиБ
Python
"""
|
|
Multi-symbol strategy screen, ranked the way the account is actually judged.
|
|
|
|
RANKING RULE (non-negotiable here): drawdown first, not profit. A candidate is
|
|
only reported as viable if it clears ALL THREE of
|
|
* cadence >= 2 trades / month (a strategy that never trades is not one)
|
|
* maxDD <= the prop limit
|
|
* ret/DD >= 2
|
|
Profit ranking is what produces the curve-fit winners this project keeps
|
|
re-discovering, so it is deliberately not the sort key.
|
|
|
|
COSTS ARE REAL. The exported `spread` column is in POINTS at each bar, and
|
|
point size is recovered from the decimal count the exporter wrote (it used
|
|
DoubleToString(price, digits), so the file's precision IS the symbol's digits).
|
|
A long round trip pays the full spread once: you buy the ask and sell the bid.
|
|
This matters more than any parameter -- the decade verdict on this project was
|
|
that spread is the whole gap between paper and traded results.
|
|
|
|
CONTROL. Every candidate is compared against a LONG-ONLY RANDOM ENTRY with a
|
|
matched holding period, not a two-sided coin flip. On a drifting index a
|
|
two-sided control is a straw man: it loses money by construction, so beating
|
|
it proves nothing. Long bias on an index is not a flaw, it is the asset.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
COMMON = r"C:\Users\admin\AppData\Roaming\MetaQuotes\Terminal\Common\Files"
|
|
|
|
|
|
# ------------------------------------------------------------------ loading
|
|
def load(symbol: str, period: str = "PERIOD_D1"):
|
|
path = rf"{COMMON}\bars_{symbol}_{period}.csv"
|
|
raw = np.genfromtxt(path, delimiter=",", skip_header=1, dtype=str, encoding="ansi")
|
|
ts = np.array([f"{r[0][:10].replace('.', '-')}T{r[0][11:]}" for r in raw],
|
|
dtype="datetime64[s]")
|
|
o, h, l, c = (raw[:, i].astype(float) for i in (1, 2, 3, 4))
|
|
vol = raw[:, 5].astype(float)
|
|
spread_pts = raw[:, 6].astype(float)
|
|
# digits == decimals the exporter wrote; point == 10^-digits
|
|
digits = max(len(s.split(".")[1]) if "." in s else 0 for s in raw[:20, 4])
|
|
point = 10.0 ** (-digits)
|
|
return dict(ts=ts, o=o, h=h, l=l, c=c, v=vol,
|
|
cost=spread_pts * point, point=point, digits=digits, symbol=symbol)
|
|
|
|
|
|
# --------------------------------------------------------------- indicators
|
|
def sma(x, n):
|
|
out = np.full(len(x), np.nan)
|
|
cs = np.concatenate([[0.0], np.cumsum(x)])
|
|
out[n - 1:] = (cs[n:] - cs[:-n]) / n
|
|
return out
|
|
|
|
|
|
def rolling_std(x, n):
|
|
out = np.full(len(x), np.nan)
|
|
cs = np.concatenate([[0.0], np.cumsum(x)])
|
|
cs2 = np.concatenate([[0.0], np.cumsum(x * x)])
|
|
m = (cs[n:] - cs[:-n]) / n
|
|
m2 = (cs2[n:] - cs2[:-n]) / n
|
|
out[n - 1:] = np.sqrt(np.maximum(m2 - m * m, 0.0))
|
|
return out
|
|
|
|
|
|
def atr(h, l, c, n=14):
|
|
tr = np.maximum(h[1:] - l[1:], np.maximum(np.abs(h[1:] - c[:-1]), np.abs(l[1:] - c[:-1])))
|
|
tr = np.concatenate([[h[0] - l[0]], tr])
|
|
out = np.full(len(tr), np.nan)
|
|
if len(tr) <= n:
|
|
return out
|
|
out[n - 1] = tr[:n].mean()
|
|
for i in range(n, len(tr)):
|
|
out[i] = (out[i - 1] * (n - 1) + tr[i]) / n
|
|
return out
|
|
|
|
|
|
def rsi(c, n=2):
|
|
d = np.diff(c, prepend=c[0])
|
|
up = np.where(d > 0, d, 0.0)
|
|
dn = np.where(d < 0, -d, 0.0)
|
|
au = np.full(len(c), np.nan)
|
|
ad = np.full(len(c), np.nan)
|
|
if len(c) <= n:
|
|
return au
|
|
au[n] = up[1:n + 1].mean()
|
|
ad[n] = dn[1:n + 1].mean()
|
|
for i in range(n + 1, len(c)):
|
|
au[i] = (au[i - 1] * (n - 1) + up[i]) / n
|
|
ad[i] = (ad[i - 1] * (n - 1) + dn[i]) / n
|
|
rs = au / np.where(ad > 0, ad, np.nan)
|
|
out = 100 - 100 / (1 + rs)
|
|
out[ad == 0] = 100.0
|
|
return out
|
|
|
|
|
|
# ----------------------------------------------------------------- simulate
|
|
def simulate(d, entries, side=1, exit_ma=None, max_bars=10, stop_atr=3.0, atr_n=14):
|
|
"""Bar-close signals, NEXT-BAR-OPEN fills. Returns per-trade log.
|
|
|
|
entries : bool array; entry decided on bar i, filled at open of i+1.
|
|
exit_ma : if given, exit when close crosses back to this level; else time/stop.
|
|
|
|
NO LOOKAHEAD: every exit test uses bar j's own high/low/close, and the stop
|
|
is checked against the bar's extreme before its close, so an intrabar stop
|
|
is honoured rather than assumed away.
|
|
"""
|
|
o, h, l, c = d["o"], d["h"], d["l"], d["c"]
|
|
a = atr(h, l, c, atr_n)
|
|
cost = d["cost"]
|
|
ts = d["ts"]
|
|
trades = []
|
|
i = 0
|
|
n = len(c)
|
|
while i < n - 2:
|
|
if not entries[i] or not np.isfinite(a[i]) or a[i] <= 0:
|
|
i += 1
|
|
continue
|
|
fill = i + 1
|
|
entry = o[fill]
|
|
if not np.isfinite(entry) or entry <= 0:
|
|
i += 1
|
|
continue
|
|
stop = entry - side * stop_atr * a[i]
|
|
exit_px, exit_j, reason = None, None, None
|
|
for j in range(fill, min(fill + max_bars, n)):
|
|
# stop first: within a bar we cannot know order, so assume the worse
|
|
if side > 0 and l[j] <= stop:
|
|
exit_px, exit_j, reason = stop, j, "stop"
|
|
break
|
|
if side < 0 and h[j] >= stop:
|
|
exit_px, exit_j, reason = stop, j, "stop"
|
|
break
|
|
if exit_ma is not None and np.isfinite(exit_ma[j]):
|
|
if (side > 0 and c[j] >= exit_ma[j]) or (side < 0 and c[j] <= exit_ma[j]):
|
|
exit_px, exit_j, reason = c[j], j, "target"
|
|
break
|
|
if exit_px is None:
|
|
exit_j = min(fill + max_bars - 1, n - 1)
|
|
exit_px, reason = c[exit_j], "time"
|
|
gross = side * (exit_px - entry)
|
|
net = gross - cost[fill] # full spread once, round trip
|
|
trades.append(dict(entry_i=fill, exit_i=exit_j, t=ts[fill],
|
|
ret=net / entry, gross=gross / entry, reason=reason,
|
|
bars=exit_j - fill + 1))
|
|
i = exit_j + 1 # no overlapping positions
|
|
return trades
|
|
|
|
|
|
def metrics(trades, ts, risk_frac=0.01, stop_atr=3.0):
|
|
"""Equity curve at fixed fractional risk; drawdown on that curve.
|
|
|
|
Each trade risks `risk_frac` of equity at the stop distance, so a trade's
|
|
equity impact is risk_frac * (net return / stop distance) -- i.e. R
|
|
multiples, which is how the prop limit is actually consumed.
|
|
"""
|
|
if not trades:
|
|
return None
|
|
years = (ts[-1] - ts[0]) / np.timedelta64(365, "D")
|
|
months = max(years * 12.0, 1e-9)
|
|
rets = np.array([t["ret"] for t in trades])
|
|
eq = [1.0]
|
|
for t in trades:
|
|
# R multiple: net move relative to the risked distance
|
|
eq.append(eq[-1] * (1.0 + risk_frac * t["r"]))
|
|
eq = np.array(eq)
|
|
peak = np.maximum.accumulate(eq)
|
|
dd = (peak - eq) / peak
|
|
total = eq[-1] - 1.0
|
|
maxdd = dd.max()
|
|
return dict(n=len(trades), per_month=len(trades) / months,
|
|
exp_bp=rets.mean() * 1e4, hit=float((rets > 0).mean()),
|
|
total=total, maxdd=maxdd,
|
|
ret_dd=(total / maxdd if maxdd > 1e-9 else np.nan),
|
|
cagr=(eq[-1] ** (1 / max(years, 1e-9)) - 1.0),
|
|
years=years)
|
|
|
|
|
|
def add_r(trades, d, stop_atr=3.0, atr_n=14):
|
|
"""Attach the R multiple to each trade (net return / risked distance)."""
|
|
a = atr(d["h"], d["l"], d["c"], atr_n)
|
|
for t in trades:
|
|
i = t["entry_i"] - 1
|
|
risk = stop_atr * a[i] / d["o"][t["entry_i"]]
|
|
t["r"] = t["ret"] / risk if risk > 0 else 0.0
|
|
return trades
|