| //+------------------------------------------------------------------+
|
| //| TF_Engine.mqh - Tick Forge data generator |
|
| //| Copyright (c) 2026 Joshua Dunnink. MIT License (see LICENSE). |
|
| //| |
|
| //| Reads the broker's own history for a source symbol, shifts the |
|
| //| raw timestamps, maps every price through a user function, and |
|
| //| writes the result into a custom symbol. |
|
| //+------------------------------------------------------------------+
|
| #property strict
|
| #include "TF_Expr.mqh"
|
|
|
| void TF_Status(const string s); // implemented by the host program (logs + updates the GUI)
|
|
|
| enum ENUM_TF_MODE
|
| {
|
| TF_MODE_TICKS = 0, // Real ticks (transformed) + M1 bars rebuilt from them
|
| TF_MODE_BARS = 1 // M1 bars only (tester synthesizes the ticks)
|
| };
|
|
|
| struct CTFJob
|
| {
|
| string src; // source symbol (broker data)
|
| string name; // custom symbol to write
|
| datetime from; // first day (00:00)
|
| datetime to; // last day (inclusive)
|
| int mode; // ENUM_TF_MODE
|
| long offset_sec; // raw timestamp shift, seconds (may be negative)
|
| string expr; // price function
|
| bool scale_spread; // spread follows the price factor
|
| bool auto_digits; // add digits when the price level shrinks
|
| };
|
|
|
| struct CTFResult
|
| {
|
| bool ok;
|
| string err;
|
| long ticks;
|
| long bars;
|
| int digits;
|
| double p0;
|
| };
|
|
|
| //--- helpers -------------------------------------------------------
|
| // Sleep() is not allowed in indicators; a short busy wait is enough to let a history download answer.
|
| void TF_Wait(const uint ms)
|
| {
|
| const uint t = GetTickCount();
|
| while(GetTickCount() - t < ms) { }
|
| }
|
| bool TF_IsFinite(const double v) { return MathIsValidNumber(v); }
|
|
|
| bool TF_SymbolExists(const string name, bool &custom)
|
| {
|
| custom = false;
|
| for(int i = 0; i < SymbolsTotal(false); i++)
|
| if(SymbolName(i, false) == name)
|
| {
|
| custom = (bool)SymbolInfoInteger(name, SYMBOL_CUSTOM);
|
| return true;
|
| }
|
| return false;
|
| }
|
|
|
| string TF_DataPath(const string name)
|
| {
|
| return TerminalInfoString(TERMINAL_DATA_PATH) + "\\bases\\Custom\\history\\" + name;
|
| }
|
| string TF_TickPath(const string name)
|
| {
|
| return TerminalInfoString(TERMINAL_DATA_PATH) + "\\bases\\Custom\\ticks\\" + name;
|
| }
|
|
|
| //--- the engine ----------------------------------------------------
|
| class CTFEngine
|
| {
|
| private:
|
| CTFJob m_job;
|
| CTFExpr m_ex;
|
| double m_p0;
|
| double m_span; // seconds in the range
|
| datetime m_from;
|
| int m_digits_src, m_digits_dst;
|
| double m_point_dst;
|
| long m_bad;
|
| int m_i, m_days; // stepwise run: next day index, number of days
|
| uint m_ready_at; // GetTickCount() before which no write is attempted
|
|
|
| double Map(const double price, const datetime t);
|
| double PointDst() const { return m_point_dst; }
|
| bool Prepare(CTFResult &res);
|
| bool WriteTicksDay(const datetime day, CTFResult &res);
|
| bool WriteBarsChunk(const datetime a, const datetime b, CTFResult &res);
|
| void FlushMinute(MqlRates &cur, MqlRates &out[], int &n);
|
| bool SetupSymbol(CTFResult &res);
|
|
|
| public:
|
| CTFEngine() : m_bad(0), m_i(0), m_days(0), m_ready_at(0), m_p0(0), m_span(0), m_from(0), m_digits_src(0), m_digits_dst(0), m_point_dst(0) {}
|
| bool Preview(const CTFJob &job, double &first, double &last, double &mid, string &err);
|
| bool Series(const CTFJob &job, datetime &tm[], double &orig[], double &neu[], string &tfname, string &err);
|
| bool Begin(const CTFJob &job, CTFResult &res); // validate, create the symbol; then call Step() until Done()
|
| bool Step(CTFResult &res); // write one day (returns false on error)
|
| bool Done() const { return m_i >= m_days; }
|
| int DayIndex() const { return m_i; }
|
| int Days() const { return m_days; }
|
| bool Run(const CTFJob &job, CTFResult &res); // all steps at once (for tools; blocks)
|
| };
|
|
|
| double CTFEngine::Map(const double price, const datetime t)
|
| {
|
| double tt = (m_span > 0.0 ? (double)(t - m_from) / m_span : 0.0);
|
| if(tt < 0.0) tt = 0.0;
|
| if(tt > 1.0) tt = 1.0;
|
| double v = m_ex.Eval(price, m_p0, tt, (double)(t - m_from) / 86400.0);
|
| if(!TF_IsFinite(v) || v <= 0.0) { m_bad++; return 0.0; }
|
| return v;
|
| }
|
|
|
| //--- first M1 open on/after the range start ---------------------------
|
| bool TF_FirstOpen(const string sym, const datetime from, const datetime to, double &p0, datetime &t0)
|
| {
|
| MqlRates r[];
|
| for(int attempt = 0; attempt < 4; attempt++)
|
| {
|
| int n = CopyRates(sym, PERIOD_M1, from, MathMin(from + 7 * 86400, to + 86400), r);
|
| if(n > 0) { p0 = r[0].open; t0 = r[0].time; return true; }
|
| TF_Wait(250);
|
| }
|
| return false;
|
| }
|
|
|
| bool CTFEngine::Preview(const CTFJob &job, double &first, double &last, double &mid, string &err)
|
| {
|
| m_job = job; m_bad = 0; err = "";
|
| if(!m_ex.Compile(job.expr)) { err = m_ex.Error(); return false; }
|
| datetime t0;
|
| if(!TF_FirstOpen(job.src, job.from, job.to, m_p0, t0)) { err = "no M1 bars for " + job.src + " in range"; return false; }
|
| m_from = job.from; m_span = (double)(job.to + 86400 - job.from);
|
| MqlRates r[];
|
| int n = CopyRates(job.src, PERIOD_M1, job.to, 1, r); // 1 bar at/ before range end
|
| double pl = (n > 0 ? r[0].close : m_p0);
|
| first = Map(m_p0, job.from);
|
| mid = Map((m_p0 + pl) / 2.0, job.from + (datetime)(m_span / 2));
|
| last = Map(pl, job.to + 86400);
|
| if(m_bad > 0) { err = "function gives zero/negative/invalid prices"; return false; }
|
| return true;
|
| }
|
|
|
| //--- overview series: whole range, source vs result, timeframe chosen from the span ---
|
| bool CTFEngine::Series(const CTFJob &job, datetime &tm[], double &orig[], double &neu[], string &tfname, string &err)
|
| {
|
| m_job = job; m_bad = 0; err = "";
|
| if(!m_ex.Compile(job.expr)) { err = m_ex.Error(); return false; }
|
| datetime t0;
|
| if(!TF_FirstOpen(job.src, job.from, job.to, m_p0, t0)) { err = "no M1 bars for " + job.src + " in range"; return false; }
|
| m_from = job.from; m_span = (double)(job.to + 86400 - job.from);
|
| double days = m_span / 86400.0;
|
| ENUM_TIMEFRAMES tf = (days <= 3 ? PERIOD_M5 : days <= 14 ? PERIOD_M30 : days <= 120 ? PERIOD_H1 : days <= 700 ? PERIOD_H4 : PERIOD_D1);
|
| tfname = StringSubstr(EnumToString(tf), 7);
|
| MqlRates r[];
|
| int n = -1;
|
| for(int a = 0; a < 3 && n <= 0; a++)
|
| {
|
| n = CopyRates(job.src, tf, job.from, job.to + 86400 - 1, r);
|
| if(n <= 0) TF_Wait(250);
|
| }
|
| if(n <= 1) { err = "no " + tfname + " bars in that range"; return false; }
|
| int stride = (n > 600 ? (n + 599) / 600 : 1), m = 0;
|
| ArrayResize(tm, n / stride + 1); ArrayResize(orig, n / stride + 1); ArrayResize(neu, n / stride + 1);
|
| for(int i = 0; i < n; i += stride)
|
| {
|
| double v = Map(r[i].close, r[i].time);
|
| if(v <= 0.0) { err = "function gives zero/negative/invalid prices"; return false; }
|
| tm[m] = r[i].time; orig[m] = r[i].close; neu[m] = v; m++;
|
| }
|
| ArrayResize(tm, m); ArrayResize(orig, m); ArrayResize(neu, m);
|
| return true;
|
| }
|
|
|
| //--- digits / contract properties --------------------------------------
|
| bool CTFEngine::SetupSymbol(CTFResult &res)
|
| {
|
| bool custom;
|
| bool exists = TF_SymbolExists(m_job.name, custom);
|
| if(exists && !custom) { res.err = "'" + m_job.name + "' is a real broker symbol - choose another name"; return false; }
|
| if(exists)
|
| {
|
| CustomRatesDelete(m_job.name, 0, LONG_MAX);
|
| CustomTicksDelete(m_job.name, 0, LONG_MAX);
|
| }
|
| else if(!CustomSymbolCreate(m_job.name, "Tick Forge", m_job.src))
|
| { res.err = StringFormat("CustomSymbolCreate failed (err %d)", GetLastError()); return false; }
|
| SymbolSelect(m_job.name, true);
|
|
|
| // trading permissions follow the source, so an Expert's orders are accepted exactly as on the real symbol
|
| const ENUM_SYMBOL_INFO_INTEGER props[] = {SYMBOL_FILLING_MODE, SYMBOL_ORDER_MODE, SYMBOL_EXPIRATION_MODE, SYMBOL_TRADE_EXEMODE, SYMBOL_TRADE_MODE};
|
| for(int k = 0; k < ArraySize(props); k++)
|
| CustomSymbolSetInteger(m_job.name, props[k], SymbolInfoInteger(m_job.src, props[k]));
|
|
|
| // sample the price factor across the range to size the digits
|
| m_digits_src = (int)SymbolInfoInteger(m_job.src, SYMBOL_DIGITS);
|
| double psrc = SymbolInfoDouble(m_job.src, SYMBOL_POINT);
|
| double sumlog = 0.0; int cnt = 0;
|
| MqlRates r[];
|
| for(int k = 0; k <= 12; k++)
|
| {
|
| datetime tk = m_job.from + (datetime)((m_job.to + 86400 - m_job.from) * k / 12);
|
| if(tk >= m_job.to + 86400) tk = m_job.to;
|
| if(CopyRates(m_job.src, PERIOD_M1, tk, 1, r) > 0 && r[0].close > 0.0)
|
| {
|
| double f = Map(r[0].close, tk);
|
| if(f > 0.0) { sumlog += MathLog10(f / r[0].close); cnt++; }
|
| }
|
| }
|
| if(m_bad > 0) { res.err = "function gives zero/negative/invalid prices on sampled data"; return false; }
|
| double lg = (cnt > 0 ? sumlog / cnt : 0.0);
|
| m_digits_dst = m_digits_src;
|
| if(m_job.auto_digits && lg < 0.0) m_digits_dst = (int)MathMin(8, m_digits_src + (int)MathRound(-lg));
|
| m_point_dst = MathPow(10.0, -m_digits_dst);
|
| res.digits = m_digits_dst;
|
|
|
| if(m_digits_dst != m_digits_src)
|
| {
|
| CustomSymbolSetInteger(m_job.name, SYMBOL_DIGITS, m_digits_dst);
|
| CustomSymbolSetDouble(m_job.name, SYMBOL_POINT, m_point_dst);
|
| double ts = SymbolInfoDouble(m_job.src, SYMBOL_TRADE_TICK_SIZE);
|
| double tv = SymbolInfoDouble(m_job.src, SYMBOL_TRADE_TICK_VALUE);
|
| double tvp = SymbolInfoDouble(m_job.src, SYMBOL_TRADE_TICK_VALUE_PROFIT);
|
| double tvl = SymbolInfoDouble(m_job.src, SYMBOL_TRADE_TICK_VALUE_LOSS);
|
| double nts = m_point_dst, ratio = (ts > 0.0 ? nts / ts : 1.0);
|
| CustomSymbolSetDouble(m_job.name, SYMBOL_TRADE_TICK_SIZE, nts);
|
| CustomSymbolSetDouble(m_job.name, SYMBOL_TRADE_TICK_VALUE, tv * ratio);
|
| CustomSymbolSetDouble(m_job.name, SYMBOL_TRADE_TICK_VALUE_PROFIT, tvp * ratio);
|
| CustomSymbolSetDouble(m_job.name, SYMBOL_TRADE_TICK_VALUE_LOSS, tvl * ratio);
|
| }
|
| m_ready_at = GetTickCount() + 1500; // a fresh custom symbol rejects its first write (err 4301): Step() waits
|
| return true;
|
| }
|
|
|
| //--- one UTC-day of ticks -----------------------------------------------
|
| void CTFEngine::FlushMinute(MqlRates &cur, MqlRates &out[], int &n)
|
| {
|
| if(cur.time == 0) return;
|
| if(ArraySize(out) <= n) ArrayResize(out, n + 1024);
|
| out[n++] = cur;
|
| }
|
|
|
| bool CTFEngine::WriteTicksDay(const datetime day, CTFResult &res)
|
| {
|
| MqlTick src[];
|
| ulong a = (ulong)day * 1000, b = ((ulong)day + 86400) * 1000 - 1;
|
| int got = -1;
|
| for(int attempt = 0; attempt < 3 && got < 0; attempt++)
|
| {
|
| ResetLastError();
|
| got = CopyTicksRange(m_job.src, src, COPY_TICKS_ALL, a, b);
|
| if(got < 0) TF_Wait(200);
|
| }
|
| if(got <= 0) return true; // weekend / no data: nothing to write
|
|
|
| MqlTick dst[]; ArrayResize(dst, got);
|
| MqlRates bars[]; ArrayResize(bars, 1500);
|
| int nb = 0;
|
| MqlRates cur; ZeroMemory(cur);
|
| long off_msc = m_job.offset_sec * 1000;
|
| double pt = m_point_dst;
|
| int w = 0;
|
| for(int i = 0; i < got; i++)
|
| {
|
| double bid = src[i].bid, ask = src[i].ask;
|
| if(bid <= 0.0) continue;
|
| datetime ot = (datetime)(src[i].time_msc / 1000);
|
| double nb_ = Map(bid, ot);
|
| if(nb_ <= 0.0) { res.err = "function produced an invalid price"; return false; }
|
| double k = nb_ / bid;
|
| double na = 0.0;
|
| if(ask > 0.0)
|
| {
|
| double sp = ask - bid;
|
| na = nb_ + (m_job.scale_spread ? sp * k : sp);
|
| }
|
| nb_ = NormalizeDouble(nb_, m_digits_dst);
|
| na = (na > 0.0 ? NormalizeDouble(na, m_digits_dst) : 0.0);
|
| dst[w].time_msc = src[i].time_msc + off_msc;
|
| dst[w].time = (datetime)(dst[w].time_msc / 1000);
|
| dst[w].bid = nb_; dst[w].ask = na; dst[w].last = 0.0; dst[w].volume = 0; dst[w].volume_real = 0.0;
|
| dst[w].flags = src[i].flags & (TICK_FLAG_BID | TICK_FLAG_ASK);
|
| if(dst[w].flags == 0) dst[w].flags = TICK_FLAG_BID | TICK_FLAG_ASK;
|
|
|
| // M1 aggregation on the shifted clock
|
| datetime mt = (datetime)((dst[w].time / 60) * 60);
|
| int spr = (na > 0.0 ? (int)MathRound((na - nb_) / pt) : 0);
|
| if(cur.time != mt)
|
| {
|
| FlushMinute(cur, bars, nb);
|
| cur.time = mt; cur.open = cur.high = cur.low = cur.close = nb_;
|
| cur.tick_volume = 1; cur.spread = spr; cur.real_volume = 0;
|
| }
|
| else
|
| {
|
| if(nb_ > cur.high) cur.high = nb_;
|
| if(nb_ < cur.low) cur.low = nb_;
|
| cur.close = nb_; cur.tick_volume++; cur.spread = spr;
|
| }
|
| w++;
|
| }
|
| FlushMinute(cur, bars, nb);
|
| if(w == 0) return true;
|
| ArrayResize(dst, w);
|
| ArrayResize(bars, nb);
|
|
|
| ResetLastError();
|
| int dt = CustomTicksReplace(m_job.name, dst[0].time_msc, dst[w - 1].time_msc, dst);
|
| if(dt < 0) { res.err = StringFormat("CustomTicksReplace failed (err %d)", GetLastError()); return false; }
|
| res.ticks += dt;
|
| if(nb > 0)
|
| {
|
| ResetLastError();
|
| int db = CustomRatesReplace(m_job.name, bars[0].time, bars[nb - 1].time + 60, bars);
|
| if(db < 0) { res.err = StringFormat("CustomRatesReplace failed (err %d)", GetLastError()); return false; }
|
| res.bars += db;
|
| }
|
| return true;
|
| }
|
|
|
| //--- bars-only chunk -----------------------------------------------------
|
| bool CTFEngine::WriteBarsChunk(const datetime a, const datetime b, CTFResult &res)
|
| {
|
| MqlRates src[];
|
| int got = -1;
|
| for(int attempt = 0; attempt < 3 && got < 0; attempt++)
|
| {
|
| ResetLastError();
|
| got = CopyRates(m_job.src, PERIOD_M1, a, b, src);
|
| if(got < 0) TF_Wait(200);
|
| }
|
| if(got <= 0) return true;
|
| MqlRates dst[]; ArrayResize(dst, got);
|
| for(int i = 0; i < got; i++)
|
| {
|
| double c = src[i].close;
|
| double o = Map(src[i].open, src[i].time), h = Map(src[i].high, src[i].time);
|
| double l = Map(src[i].low, src[i].time), cl = Map(c, src[i].time);
|
| if(o <= 0.0 || h <= 0.0 || l <= 0.0 || cl <= 0.0) { res.err = "function produced an invalid price"; return false; }
|
| double hi = MathMax(MathMax(o, h), MathMax(l, cl)), lo = MathMin(MathMin(o, h), MathMin(l, cl));
|
| double k = (c > 0.0 ? cl / c : 1.0);
|
| dst[i].time = (datetime)(src[i].time + m_job.offset_sec);
|
| dst[i].open = NormalizeDouble(o, m_digits_dst);
|
| dst[i].high = NormalizeDouble(hi, m_digits_dst);
|
| dst[i].low = NormalizeDouble(lo, m_digits_dst);
|
| dst[i].close = NormalizeDouble(cl, m_digits_dst);
|
| dst[i].tick_volume = src[i].tick_volume;
|
| dst[i].real_volume = 0;
|
| // source spread is in source points; express it in destination points
|
| double sp_price = src[i].spread * SymbolInfoDouble(m_job.src, SYMBOL_POINT) * (m_job.scale_spread ? k : 1.0);
|
| dst[i].spread = (int)MathRound(sp_price / m_point_dst);
|
| }
|
| ResetLastError();
|
| int d = CustomRatesReplace(m_job.name, dst[0].time, dst[got - 1].time + 60, dst);
|
| if(d < 0) { res.err = StringFormat("CustomRatesReplace failed (err %d)", GetLastError()); return false; }
|
| res.bars += d;
|
| return true;
|
| }
|
|
|
| bool CTFEngine::Begin(const CTFJob &job, CTFResult &res)
|
| {
|
| m_job = job; m_bad = 0; m_i = 0; m_days = 0; m_ready_at = 0;
|
| res.ok = false; res.err = ""; res.ticks = 0; res.bars = 0; res.digits = 0; res.p0 = 0.0;
|
| if(!m_ex.Compile(job.expr)) { res.err = "function: " + m_ex.Error(); return false; }
|
| if(job.to < job.from) { res.err = "end date is before start date"; return false; }
|
| datetime t0;
|
| if(!TF_FirstOpen(job.src, job.from, job.to, m_p0, t0)) { res.err = "no M1 bars for " + job.src + " in that range"; return false; }
|
| res.p0 = m_p0;
|
| m_from = job.from; m_span = (double)(job.to + 86400 - job.from);
|
| if(!SetupSymbol(res)) return false;
|
| m_days = (int)((job.to - job.from) / 86400) + 1;
|
| return true;
|
| }
|
|
|
| bool CTFEngine::Step(CTFResult &res)
|
| {
|
| if(Done()) { res.ok = true; return true; }
|
| if((int)(GetTickCount() - m_ready_at) < 0) return true; // symbol still settling
|
| datetime day = m_job.from + (datetime)m_i * 86400;
|
| bool ok = (m_job.mode == TF_MODE_TICKS ? WriteTicksDay(day, res) : WriteBarsChunk(day, day + 86400 - 60, res));
|
| if(!ok) return false;
|
| if(m_bad > 0) { res.err = "function produced an invalid price"; return false; }
|
| TF_Status(StringFormat("%s day %d/%d ticks %I64d M1 bars %I64d", TimeToString(day, TIME_DATE), m_i + 1, m_days, res.ticks, res.bars));
|
| m_i++;
|
| if(Done()) res.ok = true;
|
| return true;
|
| }
|
|
|
| bool CTFEngine::Run(const CTFJob &job, CTFResult &res)
|
| {
|
| if(!Begin(job, res)) return false;
|
| while(!Done())
|
| if(!Step(res)) return false;
|
| return true;
|
| }
|
| //+------------------------------------------------------------------+
|