298 lines
12 KiB
Python
"""VtCore.mqh / VtExposure.mqh / VtJournal.mqh against tests/reference.py.
| |||
| |||
python3 tests/check_core.py write <dir> # cases for the dump tool (files folder, sub-folder vttest) + journal paths
| |||
python3 tests/check_core.py check <dir> # compare out_<k>.txt, out_pip.txt, journal_out.txt with the twin
| |||
Cases (CASES) cover hedging and netting (reversals), partial closes, cash flow and credit, commissions/swap/fee, the
| |||
filters (symbol, magic, comment, start date), 'now' on a Monday, the 1st of a month, 1 January and a Sunday, an empty
| |||
history and a 3,000-position one. Every number of the card is compared with 1e-9 relative tolerance."""
| |||
# SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
| |||
# Copyright 2026 Allan Niñal (https://www.allanninal.dev/mql5)
| |||
import csv
| |||
import random
| |||
import sys
| |||
from pathlib import Path
| |||
| |||
sys.path.insert(0, str(Path(__file__).parent))
| |||
from reference import DAY, Engine, days_from_civil, pip_multiplier, synth_case # noqa: E402
| |||
| |||
COLS = ["time", "pos", "magic", "type", "entry", "volume", "profit", "swap", "commission", "fee", "symbol", "comment"]
| |||
| |||
| |||
def at(y, m, d, h=0, mi=0, s=0):
| |||
return days_from_civil(y, m, d) * DAY + h * 3600 + mi * 60 + s
| |||
| |||
| |||
CASES = [
| |||
dict(name="hedging, plain", kw=dict(seed=1)),
| |||
dict(name="netting reversals", kw=dict(seed=2, n_pos=800, netting=True)),
| |||
dict(name="filter symbol", kw=dict(seed=3, n_pos=500), f=dict(symbol="XAUUSD")),
| |||
dict(name="filter magic+comment", kw=dict(seed=4, n_pos=900), f=dict(magic=11, comment="SCALP")),
| |||
dict(name="now Monday 00:00:05", kw=dict(seed=5, now=at(2026, 10, 19, 0, 0, 5), n_pos=600)),
| |||
dict(name="now 1 Nov (month start)", kw=dict(seed=6, now=at(2026, 11, 1), n_pos=600, netting=True)),
| |||
dict(name="now 1 Jan (year start)", kw=dict(seed=7, now=at(2027, 1, 1, 0, 0, 1), n_pos=700)),
| |||
dict(name="start-date filter", kw=dict(seed=8, n_pos=700), f=dict(start=at(2026, 3, 1))),
| |||
dict(name="empty history", kw=dict(seed=9, n_pos=0, cash=False)),
| |||
dict(name="Sunday, 3000 positions", kw=dict(seed=10, n_pos=3000, now=at(2026, 10, 18, 22), netting=True,
| |||
magics=tuple(range(30)))),
| |||
dict(name="no cash flow", kw=dict(seed=11, n_pos=400, cash=False)),
| |||
]
| |||
PIPS = [("EURUSD", 5), ("EURUSD", 4), ("USDJPY", 3), ("USDJPY", 2), ("XAUUSD", 2), ("XAUUSD", 3), ("GOLD", 2),
| |||
("XAGUSD", 3), ("XAGUSD", 4), ("SILVER", 3), ("US30", 1), ("US30", 2), ("GER40", 1), ("BTCUSD", 2),
| |||
("BTCUSD", 0), ("XAUEUR", 2), ("AUDNZD", 6), ("EURUSD.m", 5), ("GBPJPY", 4)]
| |||
| |||
| |||
def build(k):
| |||
return synth_case(**CASES[k]["kw"])
| |||
| |||
| |||
def run_twin(deals, now, bal, journal, positions, f):
| |||
e = Engine(symbol=f.get("symbol", ""), magic=f.get("magic", -1), comment=f.get("comment", ""),
| |||
start=f.get("start", 0))
| |||
for d in deals:
| |||
e.add(d)
| |||
o = e.report(now, bal, journal)
| |||
o.update(e.positions(positions, bal))
| |||
return o
| |||
| |||
| |||
def write(out: Path):
| |||
out.mkdir(parents=True, exist_ok=True)
| |||
for k, c in enumerate(CASES):
| |||
deals, now, bal, journal, positions = build(k)
| |||
f = c.get("f", {})
| |||
with open(out / f"deals_{k}.csv", "w", newline="") as fh:
| |||
w = csv.writer(fh)
| |||
w.writerow(COLS)
| |||
for d in deals:
| |||
w.writerow([repr(d[x]) if isinstance(d[x], float) else d[x] for x in COLS])
| |||
with open(out / f"meta_{k}.csv", "w", newline="") as fh:
| |||
fh.write("key,value\n")
| |||
fh.write(f"now,{now}\nbalance,{bal!r}\nf_symbol,{f.get('symbol', '')}\nf_magic,{f.get('magic', -1)}\n")
| |||
fh.write(f"f_comment,{f.get('comment', '')}\nf_start,{f.get('start', 0)}\n")
| |||
with open(out / f"jr_{k}.csv", "w", newline="") as fh:
| |||
fh.write("day,money,pct\n")
| |||
for dn in sorted(journal):
| |||
fh.write(f"{dn},{journal[dn][0]!r},{journal[dn][1]!r}\n")
| |||
with open(out / f"pos_{k}.csv", "w", newline="") as fh:
| |||
fh.write("symbol,magic,comment,dir,volume,profit,pips,sl,risk,value,base,ccy\n")
| |||
for p in positions:
| |||
fh.write(",".join(str(p[x]) if not isinstance(p[x], float) else repr(p[x]) for x in
| |||
("symbol", "magic", "comment", "dir", "volume", "profit", "pips", "sl", "risk", "value",
| |||
"base", "ccy")) + "\n")
| |||
with open(out / "pip_test.csv", "w") as fh:
| |||
fh.write("symbol,digits\n")
| |||
for s, dg in PIPS:
| |||
fh.write(f"{s},{dg}\n")
| |||
write_journal(out)
| |||
print(f"wrote {len(CASES)} cases to {out}")
| |||
| |||
| |||
# ---- compare ------------------------------------------------------------------------------------------------------------
| |||
def parse_out(p: Path):
| |||
r = {}
| |||
for line in p.read_text().splitlines():
| |||
if "\t" in line:
| |||
k, v = line.split("\t", 1)
| |||
r[k] = v
| |||
return r
| |||
| |||
| |||
def same(a, b):
| |||
try:
| |||
x, y = float(a), float(b)
| |||
except (TypeError, ValueError):
| |||
return str(a) == str(b)
| |||
return abs(x - y) <= 1e-9 * max(1.0, abs(x), abs(y))
| |||
| |||
| |||
def diff(mt, ref):
| |||
bad = []
| |||
for k in sorted(set(mt) | set(ref)):
| |||
if k not in mt:
| |||
bad.append(f"{k}: missing in the terminal (twin {ref[k]})")
| |||
elif k not in ref:
| |||
bad.append(f"{k}: missing in twin (the terminal {mt[k]})")
| |||
elif not same(mt[k], ref[k]):
| |||
bad.append(f"{k}: the terminal {mt[k]} vs twin {ref[k]}")
| |||
return bad
| |||
| |||
| |||
def check(d: Path):
| |||
bad_total = 0
| |||
for k, c in enumerate(CASES):
| |||
p = d / f"out_{k}.txt"
| |||
if not p.exists():
| |||
print(f"case {k} ({c['name']}): out file missing")
| |||
bad_total += 1
| |||
continue
| |||
deals, now, bal, journal, positions = build(k)
| |||
ref = run_twin(deals, now, bal, journal, positions, c.get("f", {}))
| |||
mt = parse_out(p)
| |||
bad = diff(mt, ref)
| |||
print(f"case {k:2} {c['name']:26} deals {len(deals):5} trades {ref['ntrades']:5} values compared {len(ref):6} "
| |||
f"{'OK' if not bad else 'MISMATCH ' + str(len(bad))}")
| |||
for b in bad[:6]:
| |||
print(" ", b)
| |||
bad_total += len(bad)
| |||
pip = parse_out(d / "out_pip.txt")
| |||
pbad = [f"pip.{i}: {pip.get(f'pip.{i}')} vs {pip_multiplier(s, dg)} ({s},{dg})" for i, (s, dg) in enumerate(PIPS)
| |||
if not same(pip.get(f"pip.{i}"), pip_multiplier(s, dg))]
| |||
print(f"pip table {len(PIPS)} symbols: {'OK' if not pbad else 'MISMATCH'}")
| |||
for b in pbad:
| |||
print(" ", b)
| |||
bad_total += len(pbad)
| |||
print("ALL PASS: 0 mismatches" if not bad_total else f"FAILURES: {bad_total} mismatches")
| |||
return bad_total == 0
| |||
| |||
| |||
# ---- the journal ---------------------------------------------------------------------------------------------------------
| |||
class JModel:
| |||
"""Python twin of CVtJournal (and the file): memory dict day -> (money, pct); 'file' = list of lines."""
| |||
def __init__(self, files, name, ms):
| |||
self.files, self.name = files, name
| |||
self.mem = {}
| |||
self.dirty = False
| |||
self.last = ms
| |||
for dn, m, p in self.read():
| |||
self.put(dn, m, p)
| |||
self.dirty = False
| |||
| |||
def read(self):
| |||
out = []
| |||
for line in self.files.get(self.name, []):
| |||
v = line.split(",")
| |||
if len(v) >= 3 and int(v[0]) > 0:
| |||
out.append((int(v[0]), min(float(v[1]), 0.0), min(float(v[2]), 0.0)))
| |||
return out
| |||
| |||
def put(self, dn, m, p):
| |||
if dn in self.mem:
| |||
if m < self.mem[dn][0]:
| |||
self.mem[dn] = (m, p)
| |||
return True
| |||
return False
| |||
self.mem[dn] = (m, p)
| |||
return True
| |||
| |||
def sample(self, t, eq, bal, ms):
| |||
day = t // DAY
| |||
money = round(min(eq - bal, 0.0), 2)
| |||
pct = round(money / bal * 100.0 if bal > 0 else 0.0, 4)
| |||
if self.put(day, money, pct):
| |||
self.dirty = True
| |||
if self.dirty and ms - self.last >= 10000:
| |||
self.flush(False, ms)
| |||
| |||
def flush(self, force, ms):
| |||
if not self.dirty:
| |||
return
| |||
if not force and ms - self.last < 10000:
| |||
return
| |||
self.last = ms
| |||
for dn, m, p in self.read():
| |||
self.put(dn, m, p)
| |||
self.files[self.name] = ["%d,%.2f,%.4f" % (dn, self.mem[dn][0], self.mem[dn][1]) for dn in sorted(self.mem)]
| |||
self.dirty = False
| |||
| |||
| |||
def journal_script():
| |||
"""(seed file lines, [(kind, t, eq, bal, ms)]): midnight crossings, clean and crash restarts, a restart across
| |||
midnight, a three-day gap, a flat day, another chart having written a deeper low."""
| |||
rnd = random.Random(77)
| |||
d0 = days_from_civil(2026, 10, 19)
| |||
seed = ["%d,%.2f,%.4f" % (d0 - 2, -120.5, -1.205), "%d,%.2f,%.4f" % (d0 - 1, 0.0, 0.0),
| |||
"%d,%.2f,%.4f" % (d0, -35.0, -0.35)]
| |||
steps = []
| |||
t = d0 * DAY + 23 * 3600 + 50 * 60
| |||
ms = 5000
| |||
bal = 10000.0
| |||
eq = bal
| |||
for i in range(900): # 15 minutes across midnight
| |||
eq = round(bal + rnd.gauss(-20, 60) if rnd.random() < 0.7 else bal + rnd.uniform(0, 80), 2)
| |||
steps.append(("S", t, eq, bal, ms))
| |||
t += 1
| |||
ms += 1000
| |||
if i in (200, 640):
| |||
steps.append(("R", t, 0, 0, ms)) # clean restart
| |||
if i in (400, 700):
| |||
steps.append(("C", t, 0, 0, ms)) # crash restart (no flush)
| |||
if i == 450:
| |||
bal = 10110.33 # a deposit/close changed the balance
| |||
# restart across midnight and a three-day gap
| |||
steps.append(("C", t, 0, 0, ms))
| |||
t += 3 * DAY + 4000
| |||
ms += 7000
| |||
for i in range(60):
| |||
eq = round(bal - rnd.uniform(0, 300), 2)
| |||
steps.append(("S", t, eq, bal, ms))
| |||
t += 1
| |||
ms += 500 # faster than 1 s: throttle matters
| |||
steps.append(("R", t, 0, 0, ms))
| |||
t += 100
| |||
for i in range(30): # a flat day: equity never under the balance
| |||
steps.append(("S", t + DAY, bal + rnd.uniform(0, 50), bal, ms))
| |||
t += 1
| |||
ms += 1000
| |||
return seed, steps
| |||
| |||
| |||
def write_journal(out: Path):
| |||
seed, steps = journal_script()
| |||
(out / "jt_seed.csv").write_text("day,money,pct\n" + "\n".join(seed) + "\n")
| |||
with open(out / "jt_paths.csv", "w", newline="") as fh:
| |||
fh.write("kind,time,equity,balance,ms\n")
| |||
for k, t, eq, bal, ms in steps:
| |||
fh.write(f"{k},{t},{eq!r},{bal!r},{ms}\n")
| |||
| |||
| |||
def check_journal(d: Path):
| |||
p = d / "journal_out.txt"
| |||
if not p.exists():
| |||
print("journal: journal_out.txt missing")
| |||
return False
| |||
seed, steps = journal_script()
| |||
files = {"f": list(seed)}
| |||
j = JModel(files, "f", 0)
| |||
snaps, n = [], 0
| |||
for k, t, eq, bal, ms in steps:
| |||
if k == "S":
| |||
j.sample(t, eq, bal, ms)
| |||
else:
| |||
if k == "R":
| |||
j.flush(True, ms)
| |||
j = JModel(files, "f", ms)
| |||
snaps.append(("%d" % n, list(files["f"])))
| |||
n += 1
| |||
j.flush(True, 4000000000)
| |||
snaps.append(("final", list(files["f"])))
| |||
mem = ["%d,%.2f,%.4f" % (dn, j.mem[dn][0], j.mem[dn][1]) for dn in sorted(j.mem)]
| |||
exp = []
| |||
for tag, lines in snaps:
| |||
exp += ["SNAP " + tag] + lines
| |||
exp += ["MEM"] + mem
| |||
got = [x for x in p.read_text().splitlines() if x]
| |||
ok = got == exp
| |||
nsamples = sum(1 for s in steps if s[0] == "S")
| |||
nrestarts = len(steps) - nsamples
| |||
print(f"journal: {nsamples} samples, {nrestarts} restarts, {len(mem)} days; compared {len(exp)} lines: "
| |||
f"{'OK, 0 mismatches' if ok else 'MISMATCH'}")
| |||
if not ok:
| |||
for i, (a, b) in enumerate(zip(got, exp)):
| |||
if a != b:
| |||
print(" first difference at line", i, ": the terminal", a, "| twin", b)
| |||
break
| |||
if len(got) != len(exp):
| |||
print(" line counts: the terminal", len(got), "twin", len(exp))
| |||
return ok
| |||
| |||
| |||
if __name__ == "__main__":
| |||
mode, d = sys.argv[1], Path(sys.argv[2])
| |||
if mode == "write":
| |||
write(d)
| |||
elif mode == "check":
| |||
ok = check(d)
| |||
ok = check_journal(d) and ok if (d / "journal_out.txt").exists() else ok
| |||
sys.exit(0 if ok else 1)
| |||
elif mode == "journal":
| |||
sys.exit(0 if check_journal(d) else 1)
|