167 lines
7.3 KiB
Python
# SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 (see LICENSE)
| |||
"""The MT4 history scan of MonEngine.mqh (Prop Rules Monitor MT4) against an independent Python rebuild.
| |||
| |||
python3 tests/mt4/check_mt4.py <folder with the snapshot files>
| |||
| |||
Input: tests/mt4/PrgTrader.mq4 snapshots (<tag>_<SYM>_<TF>_state.csv, the engine's results at many moments) and its
| |||
final order list (_orders.csv). For each snapshot the account history at that moment is rebuilt from the orders: an
| |||
opening for every buy/sell order opened by then (not the "from #" rest of a partly closed one), the P&L (profit + swap +
| |||
commission) of every order closed by then, in time order (in one second: closes, then openings, then reading order).
| |||
Then, as PrgScanHistory specifies: closed P&L and trading days per reset day (the reset instant from the line-by-line
| |||
port in tests/check_day_start.py), the initial balance (no deposit in the tester
| |||
history: the balance minus the closed P&L), the peak balance and the peak end-of-day balance (running sums anchored to
| |||
the balance), and the day-start balance rebuilt from the deals since the reset. Configs: tests/mt4/configs.tsv."""
| |||
import csv
| |||
import sys
| |||
from datetime import datetime, timedelta, timezone
| |||
from pathlib import Path
| |||
| |||
HERE = Path(__file__).resolve().parent
| |||
ZONE = {"0": "server", "1": "Prague", "2": "UTC", "3": "NewYork", "4": "London", "5": "UTC3"}
| |||
CLOCK = {"0": "NY", "1": "NY", "2": "EET"} # AUTO = New York close in the tester
| |||
| |||
| |||
def sunday_from(y, m, d, h):
| |||
t = datetime(y, m, d, tzinfo=timezone.utc)
| |||
return t + timedelta(days=(6 - t.weekday()) % 7, hours=h)
| |||
| |||
| |||
def us_dst(u): return sunday_from(u.year, 3, 8, 7) <= u < sunday_from(u.year, 11, 1, 6)
| |||
def eu_dst(u): return sunday_from(u.year, 3, 25, 1) <= u < sunday_from(u.year, 10, 25, 1)
| |||
| |||
| |||
def zone_off(z, u):
| |||
return {"Prague": 2 if eu_dst(u) else 1, "London": 1 if eu_dst(u) else 0, "NewYork": -4 if us_dst(u) else -5,
| |||
"UTC": 0, "UTC3": 3}[z] * 3600
| |||
| |||
| |||
def broker_off(clock, srv):
| |||
if clock == "EET":
| |||
return (3 if eu_dst(srv - timedelta(hours=2)) else 2) * 3600
| |||
return (3 if us_dst(srv - timedelta(hours=2)) else 2) * 3600
| |||
| |||
| |||
def floor_to_hour(t, h):
| |||
return t - timedelta(seconds=(int(t.timestamp()) - h * 3600) % 86400)
| |||
| |||
| |||
def day_start(clock, zone, h, srv_ts):
| |||
srv = datetime.fromtimestamp(srv_ts, timezone.utc)
| |||
if zone == "server":
| |||
return int(floor_to_hour(srv, h).timestamp())
| |||
utc = srv - timedelta(seconds=broker_off(clock, srv))
| |||
loc = utc + timedelta(seconds=zone_off(zone, utc))
| |||
rl = floor_to_hour(loc, h)
| |||
ru = rl - timedelta(seconds=zone_off(zone, rl - timedelta(seconds=zone_off(zone, utc))))
| |||
return int((ru + timedelta(seconds=broker_off(clock, ru + timedelta(hours=2)))).timestamp())
| |||
| |||
| |||
def events_of(orders):
| |||
"""(time, kind, seq, value, server): kind 1 close, 2 opening (balance operations, kind 0, are not in the tester
| |||
history); server = a stop-loss / take-profit close (the tester fills it before the EA's tick)"""
| |||
ev = []
| |||
for o in orders:
| |||
ty = int(o["type"])
| |||
if ty > 1:
| |||
continue
| |||
if not o["comment"].startswith("from #"):
| |||
ev.append((int(o["open_time"]), 2, len(ev), 0.0, False))
| |||
if o["pool"] == "history":
| |||
ev.append((int(o["close_time"]), 1, len(ev), float(o["profit"]) + float(o["swap"]) + float(o["commission"]),
| |||
"[sl]" in o["comment"] or "[tp]" in o["comment"]))
| |||
ev.sort(key=lambda e: (e[0], e[1], e[2]))
| |||
return ev
| |||
| |||
| |||
def seen(e, now, final=False):
| |||
"""in the history at the snapshot: before its second, or a server close in it (the snapshot is taken at the start of
| |||
the EA's tick, before its own trades of that second)"""
| |||
return e[0] < now or (e[0] == now and (e[4] or final))
| |||
| |||
| |||
def scan(ev, now, bal, ds, final=False):
| |||
ev = [e for e in ev if seen(e, now, final)]
| |||
run = peak = eod = day_end = 0.0
| |||
day = 0
| |||
days = {} # key -> [pnl, traded]
| |||
order = []
| |||
for t, kind, _, v, _ in ev:
| |||
key = ds(t)
| |||
if key not in days:
| |||
days[key] = [0.0, False]
| |||
order.append(key)
| |||
if kind == 2:
| |||
days[key][1] = True
| |||
continue
| |||
if day and key != day:
| |||
eod = max(eod, day_end)
| |||
day = key
| |||
run += v
| |||
day_end = run
| |||
days[key][0] += v
| |||
peak = max(peak, run)
| |||
n_money = len(ev)
| |||
base = bal - run if n_money else 0.0
| |||
run, peak, eod, day_end = run + base, peak + base, eod + base, day_end + base
| |||
initial = bal - (run - base)
| |||
if initial <= 0:
| |||
initial = bal
| |||
today = ds(now)
| |||
peak_balance = max(peak, initial, bal)
| |||
peak_eod = eod
| |||
if day and day < today:
| |||
peak_eod = max(peak_eod, day_end)
| |||
peak_eod = max(peak_eod, initial)
| |||
traded = sum(1 for k in order if days[k][1])
| |||
today_closed = days[today][0] if today in days else 0.0
| |||
rebuilt = bal - sum(e[3] for e in ev if e[0] >= today)
| |||
return dict(initial=initial, peak_balance=peak_balance, peak_eod=peak_eod, trading_days=traded,
| |||
today_closed=today_closed, rebuilt_ref=rebuilt, day_start=today,
| |||
days=";".join(f"{k}:{days[k][0]:.2f}:{int(days[k][1])}" for k in order))
| |||
| |||
| |||
def main(root: Path) -> bool:
| |||
cfg = {}
| |||
for line in (HERE / "configs.tsv").read_text().splitlines():
| |||
tag, _, ov = line.partition("\t")
| |||
if tag:
| |||
cfg[tag] = dict(kv.split("=", 1) for kv in ov.split(","))
| |||
allok, snaps, files = True, 0, 0
| |||
for sf in sorted(root.glob("*_state.csv")):
| |||
stem = sf.name[:-len("_state.csv")]
| |||
tag = next(t for t in sorted(cfg, key=len, reverse=True) if stem.startswith(t + "_"))
| |||
c = cfg[tag]
| |||
clock, zone, hour = CLOCK[c.get("InpClock", "0")], ZONE[c.get("InpResetZone", "1")], int(c.get("InpResetHour", "0"))
| |||
ds = lambda t: day_start(clock, zone, hour, t) # noqa: E731
| |||
ev = events_of(list(csv.DictReader(open(root / f"{stem}_orders.csv"))))
| |||
bad, first, n = 0, "", 0
| |||
for r in csv.DictReader(open(sf)):
| |||
now, bal = int(r["now"]), float(r["balance"])
| |||
want = scan(ev, now, bal, ds, r["final"] == "1") # the last one: after the tester closed what was open
| |||
diffs = []
| |||
for k, w in want.items():
| |||
g = r[k]
| |||
if k == "days":
| |||
ok = g == w
| |||
elif k in ("trading_days", "day_start"):
| |||
ok = int(g) == int(w)
| |||
else:
| |||
ok = abs(float(g) - w) <= 0.0051 * max(1, abs(w))
| |||
if not ok:
| |||
diffs.append(f"{k} {g if k != 'days' else '...'} vs {w if k != 'days' else '...'}")
| |||
n += 1
| |||
if diffs:
| |||
bad += 1
| |||
if not first:
| |||
first = f"{datetime.fromtimestamp(now, timezone.utc):%Y-%m-%d %H:%M}: " + "; ".join(diffs[:4])
| |||
snaps += n
| |||
files += 1
| |||
allok &= bad == 0
| |||
print(("PASS " if not bad else "FAIL ") + f"{stem:30} snapshots {n - bad}/{n} clock {clock} reset {zone} {hour:02d}:00"
| |||
f" events {len(ev)}" + (f"\n first: {first}" if first else ""))
| |||
print(f"\n{'ALL PASS' if allok and files else 'FAILURES'} ({files} runs, {snaps} snapshots)")
| |||
return bool(allok and files)
| |||
| |||
| |||
if __name__ == "__main__":
| |||
sys.exit(0 if main(Path(sys.argv[1])) else 1)
|