2026-09-11 06:16:08 -04:00 | | | //+------------------------------------------------------------------+
|
| | | //| WarriorJournal.mqh |
|
| | | //| AnimateDread |
|
| | | //| |
|
| | | //| THE QUEUE. Every filter evaluation is recorded during the vote |
|
| | | //| and written to SQLite on the timer, in one transaction. |
|
| | | //| |
|
| | | //| THE SHAPE IS THE ORIGINAL ONE and it was right the first time: |
|
| | | //| collect during Direction() because that is the only place that |
|
| | | //| knows what each filter said, and FLUSH ON THE TIMER because a |
|
| | | //| write per evaluation would put SQLite in the tick path. What this |
|
| | | //| version fixes is the two things the 2025 import got wrong - an |
|
| | | //| O(n^2) sort over a bogus date key, and reading whole tables back |
|
| | | //| per buffered row - both already solved upstream in 6819bb4 and |
|
| | | //| ea2552e. Nothing here re-imports them. |
|
| | | //| |
|
| | | //| THE CONNECTION IS OPENED BY THE TIMER AND CLOSED BY THE TIMER, |
|
| | | //| and during a backtest it is not closed at all. That is not an |
|
| | | //| optimisation detail, it is the reason this can run in the tester: |
|
| | | //| open/close per flush is what made it too slow to leave on, and |
|
| | | //| "too slow to leave on" is why it was disabled in backtests for |
|
| | | //| weeks - which is why no ranking corpus existed to rank from. |
|
| | | //+------------------------------------------------------------------+
|
| | | #ifndef WARRIOR_SIMPLE_JOURNAL_MQH
|
| | | #define WARRIOR_SIMPLE_JOURNAL_MQH
|
| | |
|
2026-09-13 14:32:28 -04:00 | | | #include "DatabaseManager.mqh"
|
2026-09-11 06:16:08 -04:00 | | |
|
| | | //--- One row per filter evaluation. Deliberately small: this is written on every bar by every
|
| | | //--- module, so a wide struct here is a cost paid millions of times in a decade-long pass.
|
| | | struct SWarriorFiring
|
| | | {
|
| | | datetime when;
|
| | | string table; // "<filterID>_<pattern>_<Buy|Sell>"
|
| | | string pattern;
|
| | | string direction;
|
| | | double price;
|
| | | double vote; // this filter's signed contribution, before normalisation
|
2026-09-13 14:32:40 -04:00 | | | string result; // "Profit" | "Loss" - only resolved firings are ever written
|
| | | double maeR; // worst excursion against the firing, in units of the stop
|
| | | double mfeR; // best excursion in its favour, same units
|
| | | int barsHeld;
|
| | | double rMultiple; // THE OUTCOME THAT MATTERS: +target/stop on a target, -1 on a stop
|
| | | //--- WHAT ELSE WAS SAYING THE SAME THING. Stamped after the vote closes, not at Record() time.
|
| | | int agree;
|
| | | int oppose;
|
| | | double barVote; // the ensemble's normalised result for that bar
|
| | | int regime; // 0 chop / 1 trend / 2 mean-revert, AT THE FIRING
|
| | | };
|
| | |
|
| | | //--- A FIRING WAITING FOR ITS ANSWER.
|
| | | //---
|
| | | //--- A row that records only that a module SPOKE cannot rank anything - which is exactly why
|
| | | //--- ApplyPatternWeight() sat in this repo with no caller: FetchWinLossCounts() counts
|
| | | //--- result='Profit' against result='Loss', and nothing ever wrote either. So every firing becomes
|
| | | //--- a virtual trade and is held here until the market answers it.
|
| | | //---
|
| | | //--- THE BARRIERS ARE THE EA'S OWN. Entry, stop and target come from the vote, so a pattern is
|
| | | //--- scored on the trade it actually implies rather than on some separate notion of "right". A
|
| | | //--- module whose signal is real but smaller than the stop it would trade behind SHOULD rank badly.
|
| | | struct SWarriorPending
|
| | | {
|
| | | datetime when;
|
| | | string table;
|
| | | string pattern;
|
| | | string direction; // "Buy" or "Sell"; anything else is never resolved
|
| | | double entry;
|
| | | double stopDist; // > 0
|
| | | double targetDist; // > 0
|
| | | double vote;
|
| | | int agree; // filters voting the SAME way on this bar
|
| | | int oppose; // filters voting the other way
|
| | | double barVote; // the ensemble's normalised result
|
| | | int regime;
|
| | | double mae; // in price, against the firing
|
| | | double mfe; // in price, in its favour
|
| | | int bars;
|
2026-09-11 06:16:08 -04:00 | | | };
|
| | |
|
| | | //--- The schema. Columns are positional on read, so a new one goes at the END or every existing
|
| | | //--- database silently re-maps. Broker time, not GMT: rows written live and rows written by a
|
| | | //--- backfill landed hours apart in the same column when these disagreed.
|
| | | #define WARRIOR_FIRING_SCHEMA "ts INTEGER, year INTEGER, month INTEGER, day INTEGER, " \
|
| | | "dayOfWeek INTEGER, hour INTEGER, minutes INTEGER, " \
|
2026-09-13 14:32:40 -04:00 | | | "pattern TEXT, direction TEXT, price REAL, vote REAL, " \
|
| | | "result TEXT, maeR REAL, mfeR REAL, barsHeld INTEGER, " \
|
| | | "rMultiple REAL, agree INTEGER, oppose INTEGER, barVote REAL, regime INTEGER"
|
| | |
|
| | | //--- HOW LONG A FIRING MAY WAIT FOR AN ANSWER, in bars. A firing that has hit neither barrier by
|
| | | //--- then is DISCARDED rather than scored: it has no outcome, and inventing one - "positive at
|
| | | //--- the horizon" - would be a second, different definition of winning mixed into one column.
|
| | | //--- This biases the corpus toward firings that resolve quickly, so a pattern that is right
|
| | | //--- slowly looks like a pattern with no data. That is the honest cost to pay: the alternative
|
| | | //--- biases the WIN RATE itself rather than the sample.
|
| | | #define WARRIOR_RESOLVE_BARS 60
|
2026-09-11 06:16:08 -04:00 | | |
|
| | | class CWarriorJournal
|
| | | {
|
| | | private:
|
| | | CDatabaseManager *m_dbm;
|
| | | SWarriorFiring m_buf[];
|
2026-09-13 14:32:40 -04:00 | | | SWarriorPending m_pend[];
|
| | | int m_resolved;
|
| | | int m_expired;
|
2026-09-11 06:16:08 -04:00 | | | bool m_backtest;
|
| | | int m_written;
|
| | | int m_dropped;
|
| | |
|
| | | //--- Table names reach SQL, so they are built from an allow-list rather than interpolated.
|
| | | //--- A filter id is author-controlled today; that is not a reason to make it a SQL injection
|
| | | //--- surface tomorrow.
|
| | | bool SafeName(const string s) const
|
| | | {
|
| | | const int n = StringLen(s);
|
| | | if(n <= 0 || n > 64)
|
| | | return false;
|
| | | for(int i = 0; i < n; i++)
|
| | | {
|
| | | const ushort c = StringGetCharacter(s, i);
|
| | | const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
| | | (c >= '0' && c <= '9') || c == '_';
|
| | | if(!ok)
|
| | | return false;
|
| | | }
|
| | | return true;
|
| | | }
|
| | |
|
| | | public:
|
| | | CWarriorJournal(void) : m_dbm(NULL), m_backtest(false),
|
2026-09-13 14:32:40 -04:00 | | | m_written(0), m_dropped(0),
|
| | | m_resolved(0), m_expired(0) {}
|
2026-09-11 06:16:08 -04:00 | | | ~CWarriorJournal(void) {}
|
| | |
|
| | | void Bind(CDatabaseManager *dbm, const bool isBacktest)
|
| | | { m_dbm = dbm; m_backtest = isBacktest; }
|
| | | int Pending(void) const { return ArraySize(m_buf); }
|
| | | int Written(void) const { return m_written; }
|
| | |
|
| | | void Record(const string filterID, const string pattern, const string direction,
|
2026-09-13 14:32:40 -04:00 | | | const double price, const double vote,
|
| | | const double stopDist, const double targetDist);
|
| | | void AdvanceBar(const double high, const double low);
|
| | | //--- STAMP THE BAR'S CONFLUENCE onto every firing opened on it. Called once, AFTER the vote
|
| | | //--- loop closes, because "how many agreed" is not knowable while the loop is still asking.
|
| | | //---
|
| | | //--- This is the input the networks in this project have never been given, and the one the
|
| | | //--- research says carries the edge: one trigger alone measured -2.15 bp, three or more
|
| | | //--- agreeing +1.39 bp out of sample, and opposed -5.80. A journal that records WHAT fired but
|
| | | //--- not WHAT ELSE was firing cannot answer the only question worth asking of it.
|
| | | void StampBar(const datetime bar, const int longVotes, const int shortVotes,
|
| | | const double barVote, const int regime)
|
| | | {
|
| | | const int n = ArraySize(m_pend);
|
| | | for(int i = 0; i < n; i++)
|
| | | {
|
| | | if(m_pend[i].when != bar || m_pend[i].bars > 0)
|
| | | continue; // only this bar's still-unscored firings
|
| | | const bool isLong = (m_pend[i].direction == "Buy");
|
| | | m_pend[i].agree = isLong ? longVotes : shortVotes;
|
| | | m_pend[i].oppose = isLong ? shortVotes : longVotes;
|
| | | m_pend[i].barVote = barVote;
|
| | | m_pend[i].regime = regime;
|
| | | }
|
| | | }
|
2026-09-11 06:16:08 -04:00 | | | bool Flush(void);
|
| | | void Report(void) const
|
| | | {
|
2026-09-13 14:32:40 -04:00 | | | //--- RESOLVED AND EXPIRED ARE BOTH REPORTED. A corpus that looks thin can be thin for two
|
| | | //--- opposite reasons - few firings, or firings that never answer inside the horizon - and
|
| | | //--- the ranking layer behaves completely differently in each case.
|
| | | PrintFormat("CWarriorJournal: %d row(s) written, %d resolved, %d expired unanswered, "
|
| | | "%d refused (unsafe name), %d queued, %d still open.",
|
| | | m_written, m_resolved, m_expired, m_dropped, ArraySize(m_buf), ArraySize(m_pend));
|
2026-09-11 06:16:08 -04:00 | | | }
|
| | | };
|
| | | //+------------------------------------------------------------------+
|
| | | void CWarriorJournal::Record(const string filterID, const string pattern, const string direction,
|
2026-09-13 14:32:40 -04:00 | | | const double price, const double vote,
|
| | | const double stopDist, const double targetDist)
|
2026-09-11 06:16:08 -04:00 | | | {
|
| | | if(m_dbm == NULL || direction == "")
|
| | | return;
|
| | | if(!SafeName(filterID) || !SafeName(pattern))
|
| | | {
|
| | | m_dropped++;
|
| | | return;
|
| | | }
|
2026-09-13 14:32:40 -04:00 | | | //--- UNRESOLVABLE FIRINGS ARE NOT RECORDED AT ALL. Without a side there is no barrier to cross,
|
| | | //--- and without a positive stop the R units below are a division by nothing. A row that can
|
| | | //--- never carry an outcome is not a cheap row, it is a permanent 'NA' diluting every count.
|
| | | if((direction != "Buy" && direction != "Sell") || stopDist <= 0.0 || targetDist <= 0.0 ||
|
| | | price <= 0.0)
|
| | | return;
|
2026-09-11 06:16:08 -04:00 | | | const string table = filterID + "_" + pattern + "_" + direction;
|
2026-09-13 14:32:40 -04:00 | | | const datetime now = TimeCurrent();
|
| | | const int n = ArraySize(m_pend);
|
2026-09-11 06:16:08 -04:00 | | | //--- IDENTITY DEDUP, same rule as the original: one row per table per bar. Direction() runs on
|
| | | //--- every tick, and without this a quiet bar with 400 ticks writes 400 identical rows and the
|
| | | //--- win rate becomes a tick-count weighting.
|
| | | for(int i = 0; i < n; i++)
|
2026-09-13 14:32:40 -04:00 | | | if(m_pend[i].table == table && m_pend[i].when == now)
|
2026-09-11 06:16:08 -04:00 | | | return;
|
2026-09-13 14:32:40 -04:00 | | | ArrayResize(m_pend, n + 1);
|
| | | m_pend[n].when = now;
|
| | | m_pend[n].table = table;
|
| | | m_pend[n].pattern = pattern;
|
| | | m_pend[n].direction = direction;
|
| | | m_pend[n].entry = price;
|
| | | m_pend[n].stopDist = stopDist;
|
| | | m_pend[n].targetDist = targetDist;
|
| | | m_pend[n].vote = vote;
|
| | | m_pend[n].agree = 0;
|
| | | m_pend[n].oppose = 0;
|
| | | m_pend[n].barVote = 0.0;
|
| | | m_pend[n].regime = 0;
|
| | | m_pend[n].mae = 0.0;
|
| | | m_pend[n].mfe = 0.0;
|
| | | //--- -1 = CREATED ON THIS BAR, NOT YET LIVE. Direction() calls Record() and then, in the same
|
| | | //--- call, AdvanceBar(High(1), Low(1)) - and High(1)/Low(1) at that moment are the bar that
|
| | | //--- just CLOSED, the signal bar, which ended before this entry existed. A pending at 0 would be
|
| | | //--- credited with that bar's whole range as its first excursion. Measured 2026-09-12: at a
|
| | | //--- crowd-long signal the bar rose into its close, so its high is near the entry (~0 traded MFE)
|
| | | //--- and its low is far below (large traded MAE) - every traded-side loss was overstated and the
|
| | | //--- crowd fade priced at +0.31R on bars where the live trade made +0.03R. The first AdvanceBar
|
| | | //--- after creation only flips this to 0; the next one applies the first REAL bar.
|
| | | m_pend[n].bars = -1;
|
| | | }
|
| | | //+------------------------------------------------------------------+
|
| | | //| ONE BAR OF THE MARKET'S ANSWER, applied to every open firing. |
|
| | | //| |
|
| | | //| Called once per bar by the vote with the CLOSED bar's range. Both |
|
| | | //| barriers are tested against the same bar, and when a bar spans |
|
| | | //| both this scores it a LOSS - the pessimistic reading. Bar data |
|
| | | //| cannot say which came first, and a scorer that resolved ties in |
|
| | | //| its own favour would report a win rate no live account could |
|
| | | //| reproduce. Every stop-and-target study in this repo that looked |
|
| | | //| too good was this assumption made the other way. |
|
| | | //+------------------------------------------------------------------+
|
| | | void CWarriorJournal::AdvanceBar(const double high, const double low)
|
| | | {
|
| | | if(high <= 0.0 || low <= 0.0 || high < low)
|
| | | return;
|
| | | int n = ArraySize(m_pend);
|
| | | for(int i = n - 1; i >= 0; i--)
|
| | | {
|
| | | if(m_pend[i].bars < 0)
|
| | | { m_pend[i].bars = 0; continue; } // born this bar: the range passed in predates it
|
| | | const bool isLong = (m_pend[i].direction == "Buy");
|
| | | const double entry = m_pend[i].entry;
|
| | | //--- Excursions, signed so that "favour" means the same thing on both sides.
|
| | | const double fav = isLong ? (high - entry) : (entry - low);
|
| | | const double adv = isLong ? (entry - low) : (high - entry);
|
| | | if(fav > m_pend[i].mfe) m_pend[i].mfe = fav;
|
| | | if(adv > m_pend[i].mae) m_pend[i].mae = adv;
|
| | | m_pend[i].bars++;
|
| | |
|
| | | const bool hitStop = (m_pend[i].mae >= m_pend[i].stopDist);
|
| | | const bool hitTarget = (m_pend[i].mfe >= m_pend[i].targetDist);
|
| | | string result = "";
|
| | | double rMult = 0.0;
|
| | | //--- THE R-MULTIPLE IS THE POINT, and the reason both barriers are carried per firing.
|
| | | //--- A binary win/loss on a SYMMETRIC barrier makes expectancy identical to win rate by
|
| | | //--- construction, which designs out the pattern worth finding: one that is right 30% of the
|
| | | //--- time and wins three times what it loses. Scored against the trade's own stop and target,
|
| | | //--- a target is +target/stop R and a stop is -1R, so mean R ranks the two correctly.
|
| | | if(hitStop)
|
| | | { result = "Loss"; rMult = -1.0; } // pessimistic on a both-touched bar
|
| | | else if(hitTarget)
|
| | | { result = "Profit"; rMult = m_pend[i].targetDist / m_pend[i].stopDist; }
|
| | | else if(m_pend[i].bars >= WARRIOR_RESOLVE_BARS)
|
| | | {
|
| | | //--- Expired with no answer. Dropped, not scored - see WARRIOR_RESOLVE_BARS.
|
| | | m_expired++;
|
| | | ArrayRemove(m_pend, i, 1);
|
| | | continue;
|
| | | }
|
| | | if(result == "")
|
| | | continue;
|
| | | const int w = ArraySize(m_buf);
|
| | | ArrayResize(m_buf, w + 1);
|
| | | m_buf[w].when = m_pend[i].when;
|
| | | m_buf[w].table = m_pend[i].table;
|
| | | m_buf[w].pattern = m_pend[i].pattern;
|
| | | m_buf[w].direction = m_pend[i].direction;
|
| | | m_buf[w].price = m_pend[i].entry;
|
| | | m_buf[w].vote = m_pend[i].vote;
|
| | | m_buf[w].result = result;
|
| | | m_buf[w].maeR = m_pend[i].mae / m_pend[i].stopDist;
|
| | | m_buf[w].mfeR = m_pend[i].mfe / m_pend[i].stopDist;
|
| | | m_buf[w].barsHeld = m_pend[i].bars;
|
| | | m_buf[w].rMultiple = rMult;
|
| | | m_buf[w].agree = m_pend[i].agree;
|
| | | m_buf[w].oppose = m_pend[i].oppose;
|
| | | m_buf[w].barVote = m_pend[i].barVote;
|
| | | m_buf[w].regime = m_pend[i].regime;
|
| | | m_resolved++;
|
| | | ArrayRemove(m_pend, i, 1);
|
| | | }
|
2026-09-11 06:16:08 -04:00 | | | }
|
| | | //+------------------------------------------------------------------+
|
| | | bool CWarriorJournal::Flush(void)
|
| | | {
|
| | | const int n = ArraySize(m_buf);
|
| | | if(m_dbm == NULL || n <= 0)
|
| | | return true;
|
| | | if(!m_dbm.BeginTransaction())
|
| | | {
|
| | | //--- LEFT QUEUED, NOT DROPPED. A failed flush that discarded its buffer would silently thin
|
| | | //--- the corpus exactly when the database is under stress - and the gap would look like a
|
| | | //--- quiet market rather than a failed write.
|
| | | Print("CWarriorJournal: could not begin a transaction; ", n, " row(s) left queued for retry.");
|
| | | return false;
|
| | | }
|
| | | string cols[] = {"ts", "year", "month", "day", "dayOfWeek", "hour", "minutes",
|
2026-09-13 14:32:40 -04:00 | | | "pattern", "direction", "price", "vote",
|
| | | "result", "maeR", "mfeR", "barsHeld", "rMultiple",
|
| | | "agree", "oppose", "barVote", "regime"};
|
2026-09-11 06:16:08 -04:00 | | | for(int i = 0; i < n; i++)
|
| | | {
|
| | | MqlDateTime t;
|
| | | TimeToStruct(m_buf[i].when, t);
|
| | | m_dbm.CreateTable(m_buf[i].table, WARRIOR_FIRING_SCHEMA); // no-op when it already exists
|
| | | string vals[];
|
2026-09-13 14:32:40 -04:00 | | | ArrayResize(vals, 20);
|
2026-09-11 06:16:08 -04:00 | | | vals[0] = IntegerToString((long)m_buf[i].when);
|
| | | vals[1] = IntegerToString(t.year);
|
| | | vals[2] = IntegerToString(t.mon);
|
| | | vals[3] = IntegerToString(t.day);
|
| | | vals[4] = IntegerToString(t.day_of_week);
|
| | | vals[5] = IntegerToString(t.hour);
|
| | | vals[6] = IntegerToString(t.min);
|
2026-09-13 14:32:40 -04:00 | | | //--- NO QUOTES. InsertTradeRecord() binds these as parameters (PlaceholderList +
|
| | | //--- PrepareAndExecuteBound), so a manual "'" makes the apostrophes part of the VALUE:
|
| | | //--- every row since this was written stored 'Pattern_0' rather than Pattern_0. It went
|
| | | //--- unnoticed because the table NAME carries the real key and nothing ever read these two
|
| | | //--- columns back - until `result` joined them and FetchWinLossCounts' result='Profit'
|
| | | //--- matched nothing, leaving every weight unset and the whole ranking pass silent.
|
| | | vals[7] = m_buf[i].pattern;
|
| | | vals[8] = m_buf[i].direction;
|
2026-09-11 06:16:08 -04:00 | | | vals[9] = DoubleToString(m_buf[i].price, 8);
|
| | | vals[10] = DoubleToString(m_buf[i].vote, 4);
|
2026-09-13 14:32:40 -04:00 | | | vals[11] = m_buf[i].result;
|
| | | vals[12] = DoubleToString(m_buf[i].maeR, 4);
|
| | | vals[13] = DoubleToString(m_buf[i].mfeR, 4);
|
| | | vals[14] = IntegerToString(m_buf[i].barsHeld);
|
| | | vals[15] = DoubleToString(m_buf[i].rMultiple, 4);
|
| | | vals[16] = IntegerToString(m_buf[i].agree);
|
| | | vals[17] = IntegerToString(m_buf[i].oppose);
|
| | | vals[18] = DoubleToString(m_buf[i].barVote, 4);
|
| | | vals[19] = IntegerToString(m_buf[i].regime);
|
2026-09-11 06:16:08 -04:00 | | | if(m_dbm.InsertTradeRecord(m_buf[i].table, cols, vals))
|
| | | m_written++;
|
| | | }
|
| | | if(!m_dbm.CommitTransaction())
|
| | | {
|
| | | m_dbm.RollbackTransaction();
|
| | | Print("CWarriorJournal: commit failed, rolled back; ", n, " row(s) left queued for retry.");
|
| | | return false;
|
| | | }
|
| | | ArrayResize(m_buf, 0);
|
| | | return true;
|
| | | }
|
| | | #endif // WARRIOR_SIMPLE_JOURNAL_MQH
|