"""Python twin of mql5/Include/Vitals/VtCore.mqh (+ VtExposure.mqh): trades from deals, periods, gain, floating-drawdown journal fallback, statistics, open-position summary, exposure. Operation for operation, so the dump of the MetaTrader code can be compared with tests/check_core.py. from reference import Engine, synth_case e = Engine(symbol="", magic=-1, comment="", start=0) for d in deals: e.add(d) out = e.report(now, balance, journal) # flat dict of every number the card shows Deal: dict(time, pos, magic, type 'buy'|'sell'|'cash'|'credit', entry 'in'|'out'|'inout'|'outby', volume, profit, swap, commission, fee, symbol, comment).""" # SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 # Copyright 2026 Allan NiƱal (https://www.allanninal.dev/mql5) import bisect import math import random EPS = 1e-8 DAY = 86400 # ---- calendar (server clock = whole seconds since 1970; no time zone) ------------------------------------------------ def civil_from_days(z): z += 719468 era = z // 146097 doe = z - era * 146097 yoe = (doe - doe // 1460 + doe // 36524 - doe // 146096) // 365 y = yoe + era * 400 doy = doe - (365 * yoe + yoe // 4 - yoe // 100) mp = (5 * doy + 2) // 153 d = doy - (153 * mp + 2) // 5 + 1 m = mp + 3 if mp < 10 else mp - 9 return (y + (1 if m <= 2 else 0), m, d) def days_from_civil(y, m, d): if m <= 2: y -= 1 era = y // 400 yoe = y - era * 400 doy = (153 * (m - 3 if m > 2 else m + 9) + 2) // 5 + d - 1 doe = yoe * 365 + yoe // 4 - yoe // 100 + doy return era * 146097 + doe - 719468 def weekday(day): # Monday = 0 return (day + 3) % 7 # ---- points per pip (kind of instrument and digits) ------------------------------------------------------------------ def pip_multiplier(symbol, digits): yen_or_silver = "JPY" in symbol or "XAG" in symbol or "SILVER" in symbol gold = "XAU" in symbol or "GOLD" in symbol if digits in (5, 1) or (digits == 3 and yen_or_silver) or (digits == 2 and gold): return 10 if digits in (6, 2) or (digits == 4 and yen_or_silver) or (digits == 3 and gold): return 100 return 1 # ---- the engine ------------------------------------------------------------------------------------------------------- class Engine: def __init__(self, symbol="", magic=-1, comment="", start=0): self.f_symbol, self.f_magic, self.f_comment, self.f_start = symbol, magic, comment.lower(), start self.open = {} # pos -> dict self.trades = [] self.ct = [] # time of every balance-affecting deal self.cc = [] # running sum of their pnl (cc[i] = sum up to and including i) # -- filters def pass_pos(self, symbol, magic, comment): return ((self.f_symbol == "" or symbol == self.f_symbol) and (self.f_magic < 0 or magic == self.f_magic) and (self.f_comment == "" or self.f_comment in comment.lower())) # -- one deal, in time order. Returns True when it completed a trade that passes the filters. def add(self, d): pnl = d["profit"] + d["swap"] + d["commission"] + d["fee"] if d["type"] == "credit": return False total = (self.cc[-1] if self.cc else 0.0) + pnl self.ct.append(d["time"]) self.cc.append(total) if d["type"] == "cash": return False p = self.open.get(d["pos"]) if d["entry"] == "in": if p is None: p = self.open[d["pos"]] = dict(magic=d["magic"], symbol=d["symbol"], comment=d["comment"], open_time=d["time"], vol=0.0, tvol=0.0, net=0.0, comm=0.0, swap=0.0, fee=0.0) p["vol"] += d["volume"] p["tvol"] += d["volume"] elif p is None: # the opening deal is not in the history p = self.open[d["pos"]] = dict(magic=d["magic"], symbol=d["symbol"], comment=d["comment"], open_time=d["time"], vol=d["volume"], tvol=d["volume"], net=0.0, comm=0.0, swap=0.0, fee=0.0) p["net"] += pnl p["comm"] += d["commission"] p["swap"] += d["swap"] p["fee"] += d["fee"] closed = False rest = 0.0 if d["entry"] in ("out", "outby"): p["vol"] -= d["volume"] closed = p["vol"] <= EPS elif d["entry"] == "inout": rest = d["volume"] - p["vol"] closed = True if not closed: return False ok = (self.pass_pos(p["symbol"], p["magic"], p["comment"]) and d["time"] >= self.f_start) if ok: self.trades.append(dict(open_time=p["open_time"], close_time=d["time"], volume=p["tvol"], net=p["net"], comm=p["comm"], swap=p["swap"], fee=p["fee"])) if d["entry"] == "inout" and rest > EPS: # the rest opens the other way self.open[d["pos"]] = dict(magic=p["magic"], symbol=p["symbol"], comment=p["comment"], open_time=d["time"], vol=rest, tvol=rest, net=0.0, comm=0.0, swap=0.0, fee=0.0) else: del self.open[d["pos"]] return ok # -- balance just before time t: current balance minus every deal at or after t (cash flow included) def bal_before(self, t, bal_now): i = bisect.bisect_left(self.ct, t) before = self.cc[i - 1] if i > 0 else 0.0 total = self.cc[-1] if self.cc else 0.0 return bal_now - (total - before) # -- every number of the card def report(self, now, bal_now, journal): o = {} T = self.trades nowday = now // DAY o["ntrades"] = len(T) # days with trades (trades are in close order) days = [] for t in T: dn = t["close_time"] // DAY if not days or days[-1]["day"] != dn: days.append(dict(day=dn, n=0, w=0, l=0, net=0.0, lots=0.0, fb=0.0, fbp=0.0, c=0.0, pk=0.0)) x = days[-1] x["n"] += 1 x["net"] += t["net"] x["lots"] += t["volume"] if t["net"] > 0: x["w"] += 1 elif t["net"] < 0: x["l"] += 1 x["c"] += t["net"] x["pk"] = max(x["pk"], x["c"]) x["fb"] = min(x["fb"], x["c"] - x["pk"]) dindex = {x["day"]: i for i, x in enumerate(days)} s0 = bal_now if T: s0 = self.bal_before(min(t["open_time"] for t in T), bal_now) o["S0"] = s0 for x in days: x["bal"] = self.bal_before(x["day"] * DAY, bal_now) x["fbp"] = 100.0 * x["fb"] / x["bal"] if x["bal"] > 0 else 0.0 def dd_of(dn): # (found, money, pct, approx) j = journal.get(dn) if j is not None: return True, min(j[0], 0.0), min(j[1], 0.0), False i = dindex.get(dn) if i is not None: return True, days[i]["fb"], days[i]["fbp"], True return False, 0.0, 0.0, False def row(i, kind, a, b): # calendar days [a, b) n = w = l = 0 net = lots = 0.0 for x in days: if a <= x["day"] < b: n += x["n"] w += x["w"] l += x["l"] net += x["net"] lots += x["lots"] bal = self.bal_before(a * DAY, bal_now) p = "row.%d." % i o[p + "kind"], o[p + "day"] = kind, a o[p + "n"], o[p + "w"], o[p + "l"], o[p + "net"], o[p + "lots"] = n, w, l, net, lots o[p + "gain_ok"] = 1 if bal > 0 else 0 o[p + "gain"] = 100.0 * net / bal if bal > 0 else 0.0 ok, bp, ba = False, 0.0, False for dn in range(a, b): f, m, pc, ap = dd_of(dn) if f and (not ok or pc < bp): ok, bp, ba = True, pc, ap o[p + "dd_ok"] = 1 if ok else 0 o[p + "dd_pct"], o[p + "dd_approx"] = bp, 1 if ba else 0 rows = [(0, nowday)] for x in reversed(days): if x["day"] < nowday and len(rows) < 5: rows.append((0, x["day"])) y, m, _ = civil_from_days(nowday) rows.append((1, nowday - weekday(nowday))) rows.append((2, days_from_civil(y, m, 1))) rows.append((3, days_from_civil(y, 1, 1))) for i, (kind, a) in enumerate(rows): row(i, kind, a, a + 1 if kind == 0 else nowday + 1) o["nrows"] = len(rows) # averages and the daily-return series r = [] # (return %, day) for x in days: if x["bal"] > 0: r.append((100.0 * x["net"] / x["bal"], x["day"])) o["avg_day_ok"] = 1 if r else 0 o["avg_day"] = sum(v[0] for v in r) / len(r) if r else 0.0 months = [] # (key, net, first day) for x in days: yy, mm, _ = civil_from_days(x["day"]) key = yy * 12 + mm if not months or months[-1][0] != key: months.append([key, 0.0, days_from_civil(yy, mm, 1)]) months[-1][1] += x["net"] mr = [] for key, net, d1 in months: b = self.bal_before(d1 * DAY, bal_now) if b > 0: mr.append(100.0 * net / b) o["avg_month_ok"] = 1 if mr else 0 o["avg_month"] = sum(mr) / len(mr) if mr else 0.0 # statistics n = len(T) gp = gl = 0.0 w = l = 0 comm = swp = fee = lots = 0.0 for t in T: lots += t["volume"] comm += t["comm"] swp += t["swap"] fee += t["fee"] if t["net"] > 0: w += 1 gp += t["net"] elif t["net"] < 0: l += 1 gl += -t["net"] o["st.n"], o["st.w"], o["st.l"], o["st.gp"], o["st.gl"] = n, w, l, gp, gl o["st.winrate"] = 100.0 * w / n if n else 0.0 o["st.avgwin"] = gp / w if w else 0.0 o["st.avgloss"] = -gl / l if l else 0.0 o["st.rr"] = (gp / w) / (gl / l) if w and l and gl > 0 else 0.0 o["st.exp"] = (gp - gl) / n if n else 0.0 o["st.pf_ok"] = 1 if gl > 0 else 0 o["st.pf"] = gp / gl if gl > 0 else 0.0 o["st.comm"], o["st.swap"], o["st.fee"], o["st.lots"] = comm, swp, fee, lots c = peak = s0 mdd = mdd_pct = rel = rel_money = 0.0 mdd_t = rel_t = 0 for t in T: c += t["net"] peak = max(peak, c) dd = peak - c if dd > mdd: mdd, mdd_pct, mdd_t = dd, (100.0 * dd / peak if peak > 0 else 0.0), t["close_time"] rp = 100.0 * dd / peak if peak > 0 else 0.0 if rp > rel: rel, rel_money, rel_t = rp, dd, t["close_time"] o["st.maxdd_money"], o["st.maxdd_pct"], o["st.maxdd_time"] = mdd, mdd_pct, mdd_t o["st.rel_pct"], o["st.rel_money"], o["st.rel_time"] = rel, rel_money, rel_t rs = [v[0] for v in r if v[1] >= now // DAY - 59] # last 60 calendar days if len(rs) >= 2: mean = sum(rs) / len(rs) var = sum((v - mean) ** 2 for v in rs) / (len(rs) - 1) sd = math.sqrt(var) o["st.sharpe_ok"] = 1 if sd > 1e-12 else 0 o["st.sharpe"] = sum(rs) / sd if sd > 1e-12 else 0.0 else: o["st.sharpe_ok"], o["st.sharpe"] = 0, 0.0 o["st.best_ok"] = 1 if r else 0 if r: bi = wi = 0 for i, v in enumerate(r): if v[0] > r[bi][0]: bi = i if v[0] < r[wi][0]: wi = i o["st.best_pct"], o["st.best_day"] = r[bi] o["st.worst_pct"], o["st.worst_day"] = r[wi] else: o["st.best_pct"] = o["st.best_day"] = 0 o["st.worst_pct"] = o["st.worst_day"] = 0 o["st.first_day"] = T[0]["close_time"] // DAY if T else 0 for i, t in enumerate(T): p = "trade.%d." % i o[p + "close"], o[p + "net"] = t["close_time"], t["net"] # days o["ndays"] = len(days) for i, x in enumerate(days): p = "day.%d." % i ok, m_, pc, ap = dd_of(x["day"]) o[p + "day"], o[p + "n"], o[p + "net"], o[p + "bal"] = x["day"], x["n"], x["net"], x["bal"] o[p + "dd_money"], o[p + "dd_pct"], o[p + "dd_approx"] = m_, pc, 1 if ap else 0 # journal-only days are not listed; a one-line summary of the journal o["njournal"] = len(journal) return o # -- open positions (summary and exposure) ----------------------------------------------------------------------- def positions(self, pos, balance): o = {} P = [p for p in pos if self.pass_pos(p["symbol"], p["magic"], p["comment"])] for name, sel in (("long", lambda p: p["dir"] == 0), ("short", lambda p: p["dir"] == 1), ("total", lambda p: True)): n, lots, pl, pips = 0, 0.0, 0.0, 0.0 for p in P: if sel(p): n += 1 lots += p["volume"] pl += p["profit"] pips += p["pips"] o["open.%s.n" % name], o["open.%s.lots" % name] = n, lots o["open.%s.pl" % name], o["open.%s.pips" % name] = pl, pips risk, nst = 0.0, 0 first = "" for p in P: if p["sl"] > 0: risk += p["risk"] else: nst += 1 if first == "": first = "%s %s %.2f" % (p["symbol"], "long" if p["dir"] == 0 else "short", p["volume"]) o["open.risk"] = risk o["open.risk_pct"] = 100.0 * risk / balance if balance > 0 else 0.0 o["open.nostop"] = nst o["open.nostop_first"] = first ex = {} names = [] for p in P: s = 1.0 if p["dir"] == 0 else -1.0 if p["base"] == p["ccy"]: for k, v in ((p["symbol"], s * p["value"]),): if k not in ex: names.append(k) ex[k] = ex.get(k, 0.0) + v else: for k, v in ((p["base"], s * p["value"]), (p["ccy"], -s * p["value"])): if k not in ex: names.append(k) ex[k] = ex.get(k, 0.0) + v rows = sorted(ex.items(), key=lambda kv: (-abs(kv[1]), kv[0])) o["open.nex"] = len(rows) for i, (k, v) in enumerate(rows): o["open.ex.%d.name" % i], o["open.ex.%d.net" % i] = k, v return o # ---- synthetic histories ---------------------------------------------------------------------------------------------- SYMS = [("EURUSD", 5, 1.08), ("GBPJPY", 3, 190.0), ("XAUUSD", 2, 2400.0), ("USDJPY", 3, 150.0), ("US30", 1, 38000.0), ("BTCUSD", 2, 60000.0)] COMMENTS = ["", "", "trend", "scalp 1", "Manual", "[sl 1.0]"] def synth_case(seed, n_pos=300, magics=(0, 11, 22, 771500), symbols=SYMS, now=None, netting=False, cash=True, start_bal=10000.0, span_days=480): """(deals, now, balance, journal, positions): deals in time order, 'now' near the end, balance = start + all pnl.""" rnd = random.Random(seed) t_end = now if now is not None else days_from_civil(2026, 10, 21) * DAY + 14 * 3600 t0 = t_end - span_days * DAY ev = [] for i in range(n_pos): m = rnd.choice(magics) sym, _dig, _px = rnd.choice(symbols) comment = rnd.choice(COMMENTS) t = t0 + rnd.randint(0, span_days * DAY - 3 * DAY) vol = round(rnd.choice([0.01, 0.02, 0.05, 0.1, 0.3, 1.0]), 2) typ = rnd.choice(["buy", "sell"]) pid = 5000 + i ev.append(dict(time=t, pos=pid, magic=m, type=typ, entry="in", volume=vol, profit=0.0, swap=0.0, commission=-round(vol * 3.5, 2), fee=0.0, symbol=sym, comment=comment)) if rnd.random() < 0.06: continue # still open parts = rnd.choice([1, 1, 1, 2, 3]) if vol >= 0.03 else 1 left = vol for k in range(parts): t += rnd.randint(60, 200_000) v = left if k == parts - 1 else round(left / 2, 2) left = round(left - v, 2) kind = "out" if netting and k == parts - 1 and rnd.random() < 0.08: kind = "inout" v = round(v + rnd.choice([0.01, 0.1]), 2) elif rnd.random() < 0.03: kind = "outby" ev.append(dict(time=t, pos=pid, magic=0 if (m and rnd.random() < 0.15) else m, type="sell" if typ == "buy" else "buy", entry=kind, volume=v, profit=round(rnd.gauss(0.5, 30) * v * 10, 2), swap=round(rnd.gauss(0, 1), 2) if rnd.random() < 0.4 else 0.0, commission=-round(v * 3.5, 2), fee=-0.05 if rnd.random() < 0.05 else 0.0, symbol=sym, comment=rnd.choice(["", "[tp 1.1]", "[sl 1.0]"]))) if cash: ev.append(dict(time=t0 + 100, pos=0, magic=0, type="cash", entry="in", volume=0.0, profit=start_bal, swap=0.0, commission=0.0, fee=0.0, symbol="", comment="deposit")) for _ in range(4): ev.append(dict(time=t0 + rnd.randint(100, span_days * DAY - 10), pos=0, magic=0, type="cash", entry="in", volume=0.0, profit=round(rnd.choice([-1, 1]) * rnd.uniform(100, 2000), 2), swap=0.0, commission=0.0, fee=0.0, symbol="", comment="transfer")) ev.append(dict(time=t0 + rnd.randint(100, span_days * DAY - 10), pos=0, magic=0, type="credit", entry="in", volume=0.0, profit=500.0, swap=0.0, commission=0.0, fee=0.0, symbol="", comment="credit")) ev = [e for e in ev if e["time"] <= t_end] ev.sort(key=lambda d: (d["time"], d["pos"])) bal = start_bal + sum(e["profit"] + e["swap"] + e["commission"] + e["fee"] for e in ev if e["type"] != "credit") if cash: bal -= start_bal # start_bal came in as the deposit deal # journal: some recent days, some old journal = {} nd = t_end // DAY for dd in range(nd - 60, nd + 1): if rnd.random() < 0.55: mny = -round(rnd.uniform(0, 400), 2) if rnd.random() < 0.9 else 0.0 journal[dd] = (mny, round(mny / 10000.0 * 100.0, 4)) # open positions positions = [] for _ in range(rnd.randint(0, 6)): sym, dig, px = rnd.choice(symbols) base = sym[:3] if len(sym) == 6 else sym ccy = sym[3:] if len(sym) == 6 else sym d = rnd.choice([0, 1]) v = round(rnd.choice([0.1, 0.3, 0.6]), 2) has = rnd.random() < 0.7 positions.append(dict(symbol=sym, magic=rnd.choice(magics), comment=rnd.choice(COMMENTS), dir=d, volume=v, profit=round(rnd.gauss(0, 60), 2), pips=round(rnd.gauss(0, 25), 1), sl=px * 0.99 if has else 0.0, risk=-round(rnd.uniform(20, 200), 2) if has else 0.0, value=round(v * rnd.uniform(5000, 120000), 2), base=base, ccy=ccy)) return ev, t_end, round(bal, 2), journal, positions