| """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)
|