Warrior_EA/Database/WarriorJournal.mqh

363 lines
18 KiB
MQL5

//+------------------------------------------------------------------+
//| 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
#include "DatabaseManager.mqh"
//--- 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
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;
};
//--- 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, " \
"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
class CWarriorJournal
{
private:
CDatabaseManager *m_dbm;
SWarriorFiring m_buf[];
SWarriorPending m_pend[];
int m_resolved;
int m_expired;
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),
m_written(0), m_dropped(0),
m_resolved(0), m_expired(0) {}
~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,
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;
}
}
bool Flush(void);
void Report(void) const
{
//--- 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));
}
};
//+------------------------------------------------------------------+
void CWarriorJournal::Record(const string filterID, const string pattern, const string direction,
const double price, const double vote,
const double stopDist, const double targetDist)
{
if(m_dbm == NULL || direction == "")
return;
if(!SafeName(filterID) || !SafeName(pattern))
{
m_dropped++;
return;
}
//--- 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;
const string table = filterID + "_" + pattern + "_" + direction;
const datetime now = TimeCurrent();
const int n = ArraySize(m_pend);
//--- 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++)
if(m_pend[i].table == table && m_pend[i].when == now)
return;
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);
}
}
//+------------------------------------------------------------------+
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",
"pattern", "direction", "price", "vote",
"result", "maeR", "mfeR", "barsHeld", "rMultiple",
"agree", "oppose", "barVote", "regime"};
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[];
ArrayResize(vals, 20);
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);
//--- 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;
vals[9] = DoubleToString(m_buf[i].price, 8);
vals[10] = DoubleToString(m_buf[i].vote, 4);
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);
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