forked from mnbvc188199/Warrior_EA
- Replaced standard library signal modules with custom implementations to allow for named patterns and improved voting. - Added new input parameters for module weights, allowing for optimization of individual signal contributions. - Enhanced the management of trades with new options for breakeven and management cut. - Introduced a mechanism for dynamic ranking of signal weights based on historical performance. - Improved initialization logic to ensure proper registration of filters and handling of trading conditions. - Added detailed logging for trading permissions and account status during initialization.
363 lines
18 KiB
MQL5
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
|