Warrior_EA/Expert/WarriorVote.mqh

832 lines
44 KiB
MQL5

//+------------------------------------------------------------------+
//| WarriorVote.mqh |
//| AnimateDread |
//| |
//| THE ROOT SIGNAL. It holds the filters, runs the standard |
//| library's own vote arithmetic, and records what every filter said |
//| so the database can rank them later. |
//| |
//| WHY Direction() IS OVERRIDDEN AT ALL. CExpertSignal::Direction() |
//| already does exactly the right arithmetic and this class does not |
//| change one term of it. What the stdlib cannot do is tell anyone |
//| WHICH filter said what - it adds each contribution and discards |
//| the attribution. The database needs precisely that attribution, |
//| so the loop is written out here with a single extra line in it. |
//| The arithmetic below is the stdlib's, term for term: |
//| |
//| result = m_weight * (LongCondition() - ShortCondition()) |
//| number = (result == 0) ? 0 : 1 |
//| for each filter: result += filter.Direction(); number++ |
//| EMPTY_VALUE from any filter aborts the whole vote |
//| result /= number |
//| |
//| ⚠ ABSTAINERS SIT IN THE DIVISOR. `number` counts every filter |
//| that answered, including the ones that returned 0. That is the |
//| stdlib's behaviour and it is kept deliberately - a consensus of |
//| twenty in which three agree is not the same claim as a consensus |
//| of three in which three agree. It does mean the threshold means |
//| something different on every roster, which is why the resolved |
//| ladder is printed at init instead of being left to be assumed. |
//+------------------------------------------------------------------+
#ifndef WARRIOR_SIMPLE_VOTE_MQH
#define WARRIOR_SIMPLE_VOTE_MQH
#include "WarriorSignal.mqh"
#include "..\Enumerations\WarriorEnums.mqh"
#include "..\Database\WarriorJournal.mqh"
#include "..\System\TradeChecks.mqh"
#include "..\System\ManagementNet.mqh"
class CWarriorVote : public CWarriorSignal
{
protected:
CWarriorJournal *m_journal; // not owned; may be NULL, in which case nothing is recorded
//--- OWNED AND REGISTERED HERE. An indicator that is merely Create()d is never refreshed by the
//--- expert - CIndicators does that, and only for what has been added to it. A standalone CiATR
//--- reads 0.0 forever, which made Params() refuse every entry and the EA place no trades at all
//--- while every other diagnostic looked healthy.
CiATR m_atr;
int m_atrPeriod;
double m_stopAtr; // stop distance, in ATR
double m_targetAtr; // target distance, in ATR (0 = no target)
int m_refusedNoAtr; // entries declined because ATR was not readable yet
int m_refusedClosed;// entries declined because the market was shut
//--- WHICH SIDE MAY OPEN. The stdlib has no such switch: CheckOpenShort() fires on any vote at
//--- or below -ThresholdOpen, and CheckReverseLong() flips a long into a short on the same test.
//--- The EA carried a Direction input for days that was applied NOWHERE - the dip-buy's exit
//--- vote (a "sell" so ThresholdClose can act) opened 36 real shorts on SP500, every one a loss.
WARRIOR_DIRECTION m_allowed;
int m_refusedSide; // opens declined because that side is switched off
int m_fadeAt; // fade the crowd at this many agreeing filters; 0 = off
int m_faded; // bars inverted this run
int m_fadeStood; // bars stood aside because the crowd was not big enough
//--- THE MANAGEMENT MODEL AND ITS PER-TICKET LATCH. See ManagementNet.mqh for the question.
CManagementNet m_mgmt;
double m_mgCut; // P(continue) below this = get out at the crossing
int m_mgMinBars; // history needed before the first fit
int m_mgRetrainBars; // refit every this many bars (0 = once)
int m_mgTrainedAtBars;
datetime m_mgLastTrain;
bool m_mgTrained;
//--- Latched when the ticket changes. R is the ORIGINAL risk and is never recomputed: reading
//--- it from the CURRENT stop is the bug that once made breakeven destroy the trail.
ulong m_mgTicket;
double m_mgEntry, m_mgRisk, m_mgMae;
int m_mgBars;
bool m_mgCrossed; // +0.5R has been touched, so the model has been asked
bool m_mgExit; // ...and it said get out
int m_mgAsked; // crossings the model was asked about, this run
int m_mgExited; // ...and how many it closed early
int m_voteHist[101];// |vote| in 1% buckets, sampled once per bar
int m_voteBars;
datetime m_voteBar;
public:
CWarriorVote(void) : m_journal(NULL), m_atrPeriod(14), m_stopAtr(2.0),
m_targetAtr(4.0), m_refusedNoAtr(0), m_refusedClosed(0),
m_allowed(DIR_BOTH), m_refusedSide(0),
m_fadeAt(0), m_faded(0), m_fadeStood(0),
m_mgCut(0.0), m_mgMinBars(750), m_mgRetrainBars(500), m_mgTrainedAtBars(0), m_mgLastTrain(0),
m_mgTrained(false), m_mgTicket(0), m_mgEntry(0.0),
m_mgRisk(0.0), m_mgMae(0.0), m_mgBars(0),
m_mgCrossed(false), m_mgExit(false),
m_mgAsked(0), m_mgExited(0),
m_voteBars(0), m_voteBar(0)
{
m_id = "VOTE";
ArrayInitialize(m_voteHist, 0);
}
~CWarriorVote(void) {}
void Journal(CWarriorJournal *j) { m_journal = j; }
//--- THE BARRIERS, IN ATR. The stdlib's own OpenLongParams takes m_stop_level/m_take_level as a
//--- fixed number of POINTS, which is the one thing a stop must not be: 200 points is a tight
//--- stop on one symbol and an absurd one on the next, and the same number changes meaning as
//--- volatility moves. Set the multiples here and the levels are computed per trade from ATR.
void Barriers(const int atrPeriod, const double stopAtr, const double targetAtr)
{ m_atrPeriod = atrPeriod; m_stopAtr = stopAtr; m_targetAtr = targetAtr; }
virtual bool InitIndicators(CIndicators *indicators) override;
virtual bool OpenLongParams(double &price, double &sl, double &tp, datetime &expiration) override
{ return Params(true, price, sl, tp, expiration); }
virtual bool OpenShortParams(double &price, double &sl, double &tp, datetime &expiration) override
{ return Params(false, price, sl, tp, expiration); }
//--- THE DIRECTION SWITCH, on the two entry checks and therefore - through the stdlib's own
//--- CheckReverseLong/Short, which call these - on reversals too. Closes are untouched: a side
//--- that is switched off can still be exited if a position somehow exists.
void Allowed(const WARRIOR_DIRECTION d) { m_allowed = d; }
WARRIOR_DIRECTION Allowed(void) const { return m_allowed; }
virtual bool CheckOpenLong(double &price, double &sl, double &tp, datetime &expiration) override
{
if(m_allowed == DIR_SHORT)
{ m_refusedSide++; return false; }
return CWarriorSignal::CheckOpenLong(price, sl, tp, expiration);
}
virtual bool CheckOpenShort(double &price, double &sl, double &tp, datetime &expiration) override
{
if(m_allowed == DIR_LONG)
{ m_refusedSide++; return false; }
return CWarriorSignal::CheckOpenShort(price, sl, tp, expiration);
}
//--- How many filters are registered. This is the divisor the vote is normalised by, so it is
//--- what turns a threshold percentage into "how many voters must agree" - the EA prints both.
int FilterCount(void) const { return m_filters.Total(); }
virtual double Direction(void) override;
//--- Bars whose |vote| reached `pct`. Buckets are 1% and truncate downward, which is how the
//--- comparison in CheckOpenLong behaves, so this counts the same population the threshold would.
int BarsAtOrAbove(const int pct) const
{
int n = 0;
for(int b = (pct < 0 ? 0 : (pct > 100 ? 100 : pct)); b <= 100; b++)
n += m_voteHist[b];
return n;
}
void ReportLadder(void) const;
//--- THE SELF-RANKING PASS. Restored from ac57a72, where it lived inside the 4,000-line
//--- CExpertSignalCustom; the arithmetic is unchanged, only its home is.
void Rerank(CDatabaseManager *dbm);
//--- 0 disables the management model entirely; otherwise P(continue) below this exits at +0.5R.
void ManagementCut(const double v) { m_mgCut = v; }
void FadeAt(const int n) { m_fadeAt = n; }
void TrainManagementIfDue(void);
virtual bool CheckCloseLong(double &price) override;
virtual bool CheckCloseShort(double &price) override;
protected:
bool Params(const bool isLong, double &price, double &sl, double &tp,
datetime &expiration);
//--- A rate shrunk toward a prior. hits/n is the module's own evidence; priorPct/priorN is the
//--- pool it belongs to, expressed as that many pseudo-observations. From System\BinomialStats
//--- at ac57a72, unchanged.
static double ShrunkRatePct(const double hits, const double n,
const double priorPct, const double priorN)
{
const bool havePrior = (priorN > 0.0 && MathIsValidNumber(priorPct) && priorPct >= 0.0);
if(!MathIsValidNumber(hits) || !MathIsValidNumber(n) || n <= 0.0)
return havePrior ? priorPct : 0.0; // no evidence => the prior IS the estimate
if(!havePrior)
return 100.0 * hits / n;
return (hits + priorN * (priorPct / 100.0)) * 100.0 / (n + priorN);
}
//--- Rounded to the nearest 10, as it always was: a weight is an ordinal here, and reporting a
//--- win rate to the percentage point claims a precision 100 samples cannot support.
static int NormalizeWinRate(const double pct) { return (int)MathRound(pct / 10.0) * 10; }
//--- The state AT A CROSSING BAR. `shift` is that bar; nothing after it is read.
bool MgmtFeatures(double &x[], const int shift, const int barsToCross,
const double maeBeforeR, const bool isLong);
bool MgmtDecide(const bool isLong);
};
//+------------------------------------------------------------------+
//| Entry at market, stop and target a multiple of ATR from it. |
//+------------------------------------------------------------------+
bool CWarriorVote::Params(const bool isLong, double &price, double &sl, double &tp,
datetime &expiration)
{
if(m_symbol == NULL)
return false;
const double atr = m_atr.Main(1); // the CLOSED bar - bar 0 is still forming
//--- REFUSE RATHER THAN GUESS. An unreadable ATR during warm-up would otherwise produce a zero
//--- distance, and a zero-distance stop is how a position sizer is handed a division by nothing.
if(atr <= 0.0 || !MathIsValidNumber(atr))
{
m_refusedNoAtr++;
return false;
}
const int digits = m_symbol.Digits();
const double entry = isLong ? m_symbol.Ask() : m_symbol.Bid();
if(entry <= 0.0)
return false;
const double stopDist = m_stopAtr * atr;
const double targetDist = m_targetAtr * atr;
price = NormalizeDouble(entry, digits);
sl = NormalizeDouble(isLong ? entry - stopDist : entry + stopDist, digits);
tp = (m_targetAtr <= 0.0) ? 0.0
: NormalizeDouble(isLong ? entry + targetDist : entry - targetDist, digits);
expiration = 0; // market order: nothing to expire
//--- THE FULL PRE-TRADE CHECKLIST, on the one path that opens a trade.
//---
//--- Everything in System\\TradeChecks.mqh existed before today and NONE of it was called: the
//--- entry path reached the broker with nothing between it and the server. The visible symptom
//--- was "Market closed" rejections, but a rejection is not a harmless no-op - the firing is
//--- discarded, so the backtest quietly measures a strategy that skips whichever signals land
//--- in a session gap. That is a rule nobody chose and it was never in the report.
//---
//--- TCCanOpen() may ADJUST sl/tp (broker minimum distance, freeze band), which is why they are
//--- passed after they are computed rather than before.
//---
//--- THE VOLUME PROBE IS THE MINIMUM LOT, deliberately. Params() does not size the position -
//--- CWarriorMoney does - so the question asked here is the weaker but still decisive one: is
//--- ANY trade possible at this moment? If the smallest legal volume is refused, no size the
//--- money module chooses can succeed, and finding that out here names the reason.
double probe = m_symbol.LotsMin();
string why = "";
if(!TCCanOpen(m_symbol.Name(), (isLong ? ORDER_TYPE_BUY : ORDER_TYPE_SELL),
probe, price, sl, tp, why))
{
m_refusedClosed++;
TCLog("entry-gate", "CWarriorVote: entry declined - " + why);
return false;
}
return true;
}
//+------------------------------------------------------------------+
//| The base registers the child filters; this adds the one indicator |
//| the root itself needs. Order matters: the base call first, so a |
//| filter that fails to initialise is reported as such. |
//+------------------------------------------------------------------+
bool CWarriorVote::InitIndicators(CIndicators *indicators)
{
if(indicators == NULL || !CWarriorSignal::InitIndicators(indicators))
return false;
if(!indicators.Add(GetPointer(m_atr)))
{
Print("CWarriorVote: could not add ATR to the indicator collection");
return false;
}
if(!m_atr.Create(m_symbol.Name(), m_period, m_atrPeriod))
{
Print("CWarriorVote: could not create ATR");
return false;
}
return true;
}
//+------------------------------------------------------------------+
double CWarriorVote::Direction(void)
{
double result = m_weight * (LongCondition() - ShortCondition());
int number = (result == 0.0) ? 0 : 1;
int longVotes = 0, shortVotes = 0; // how many filters took each side, for the journal
double fadedResult = EMPTY_VALUE; // EMPTY_VALUE = not faded; the vote returns `result`
const int total = m_filters.Total();
for(int i = 0; i < total; i++)
{
const long mask = ((long)1) << i;
if((m_ignore & mask) != 0)
continue;
//--- PLAIN CExpertSignal, so MQL5's OWN modules (CSignalMA, CSignalRSI, CSignalMACD, ...)
//--- drop straight in with no wrapper and no copy - which is how the wizard EA gets its
//--- indicators and there is no reason for this EA to do it differently.
CExpertSignal *filter = m_filters.At(i);
if(filter == NULL)
continue;
//--- Only OUR modules can be journalled, because only they name the pattern that matched.
//--- A stdlib module still votes; it simply records nothing, and that is a fair trade for
//--- getting eleven indicators for free.
CWarriorSignal *ours = dynamic_cast<CWarriorSignal *>(filter);
//--- Cleared BEFORE the call so a pattern left over from the previous bar can never be
//--- attributed to this one. A module that matches nothing this bar records nothing.
if(ours != NULL)
ours.ClearActive();
const double direction = filter.Direction();
//--- A veto. EMPTY_VALUE is the stdlib's prohibition signal and it ends the vote outright -
//--- no threshold, no arithmetic. Session, news and risk filters speak through this.
if(direction == EMPTY_VALUE)
return EMPTY_VALUE;
//--- 🛑 THE ONE ADDED LINE, and the reason this override exists. Every evaluation is recorded,
//--- not only the ones that become trades: a module's win rate is meaningless unless the
//--- firings that did NOT trade are in the same table as the ones that did.
//--- THE BARRIERS ARE THE TRADE'S OWN, and that is what makes the R-multiple meaningful.
//---
//--- A symmetric barrier was tried and is wrong for this: it forces every win to +1R and
//--- every loss to -1R, so expectancy becomes identical to win rate by construction and the
//--- pattern most worth finding - right 30% of the time, wins three times what it loses -
//--- cannot appear in the ranking at all. Scored against the stop and target the EA actually
//--- places, a firing resolves to +target/stop R or -1R, and mean R separates them.
//--- CONFIRMATION PATTERNS ARE NOT RECORDED. They are true on nearly every bar, so they would
//--- dominate the corpus by sheer count while carrying the least information of anything the
//--- module can say - and the ranking would then re-weight the one pattern that was
//--- deliberately built quiet. Skipping them here is also the single biggest efficiency win
//--- available: they were roughly two fifths of every row written.
if(m_journal != NULL && ours != NULL && ours.ActivePattern() != "" &&
!ours.IsConfirmation(CWarriorSignal::PatternIndex(ours.ActivePattern())))
{
const double atrNow = m_atr.Main(1);
if(atrNow > 0.0 && MathIsValidNumber(atrNow))
m_journal.Record(ours.FilterID(), ours.ActivePattern(), ours.ActiveDirection(),
m_symbol.Bid(), direction,
m_stopAtr * atrNow,
(m_targetAtr > 0.0 ? m_targetAtr : m_stopAtr) * atrNow);
}
const double signed_ = ((m_invert & mask) != 0) ? -direction : direction;
if(signed_ > 0.0) longVotes++;
else if(signed_ < 0.0) shortVotes++;
result += signed_;
number++;
}
if(number != 0)
result /= number;
//--- FADE THE CROWD - see WARRIOR_FADE for the measurement. The journal is stamped with the
//--- ORIGINAL vote below, so the record keeps describing what the modules said; only what the
//--- EA does with it is inverted. Stand aside when the crowd is not big enough: the trades this
//--- mode skips are the ones measured at -0.059R, and there is no reason to keep taking them.
if(m_fadeAt > 0)
{
const int crowd = MathMax(longVotes, shortVotes);
if(crowd < m_fadeAt)
{ m_fadeStood++; fadedResult = 0.0; }
else
{
//--- The SIDE is read from the count, not the sign of `result`: a weighted sum can in
//--- principle lean against the majority, and the measurement was on the majority.
fadedResult = (longVotes >= shortVotes) ? -MathAbs(result) : MathAbs(result);
m_faded++;
}
}
//--- STAMP CONFLUENCE ONTO THIS BAR'S FIRINGS, now that the loop has finished and the counts
//--- exist. Before the vote closes the answer is not merely unknown, it is undefined - which is
//--- why this cannot live inside Record().
if(m_journal != NULL)
m_journal.StampBar(TimeCurrent(), longVotes, shortVotes, result, RegimeCode(1));
//--- ONE SAMPLE PER BAR for the ladder. Per-tick sampling would weight quiet bars by how many
//--- ticks they happened to carry, which says more about the feed than about the strategy.
const datetime bar = iTime(m_symbol.Name(), m_period, 0);
if(bar != m_voteBar)
{
m_voteBar = bar;
//--- FEED THE MARKET'S ANSWER TO THE OPEN FIRINGS, once per bar, from the CLOSED bar - bar 0
//--- is still forming and its high/low would grow under the resolver, letting a firing
//--- "reach" a barrier the bar had not reached when the decision was made.
if(m_journal != NULL)
m_journal.AdvanceBar(High(1), Low(1));
//--- Deferred fit: at OnInit the tester has almost no history, so a model built
//--- there would be built on nothing. Here Bars() is history-so-far.
TrainManagementIfDue();
int b = (int)MathAbs(result);
if(b < 0) b = 0;
if(b > 100) b = 100;
m_voteHist[b]++;
m_voteBars++;
}
return (fadedResult == EMPTY_VALUE) ? result : fadedResult;
}
//+------------------------------------------------------------------+
//| What each selectable threshold would have admitted. Printed at |
//| deinit, so choosing a threshold costs one run instead of one run |
//| per candidate. |
//+------------------------------------------------------------------+
void CWarriorVote::ReportLadder(void) const
{
if(m_voteBars <= 0)
{
Print("CWarriorVote: no bars sampled - the vote never ran.");
return;
}
const int rungs[] = {1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 40, 50};
string line = "";
for(int k = 0; k < ArraySize(rungs); k++)
{
const int n = BarsAtOrAbove(rungs[k]);
if(n <= 0 && k > 0)
break;
line += StringFormat(" %d%%:%d(%.2f%%)", rungs[k], n, 100.0 * n / m_voteBars);
}
PrintFormat("CWarriorVote: THRESHOLD LADDER over %d bar(s) - bars at or above each rung:%s"
" | this run opened at >= %d%%, admitting %.2f%% of bars.",
m_voteBars, (line == "" ? " (no bar reached 1%)" : line), m_threshold_open,
100.0 * BarsAtOrAbove(m_threshold_open) / m_voteBars);
//--- ENTRY GATES, ALWAYS PRINTED. A guard that silently declines is indistinguishable from a
//--- strategy that found nothing, and this EA has already lost a day to exactly that (an ATR
//--- that read 0.0 forever refused every entry while every other diagnostic looked healthy).
//--- A number here turns "why so few trades" into a one-line answer.
PrintFormat("CWarriorVote: ENTRY GATES - %d declined (market shut or untradeable),"
" %d declined (ATR not readable yet), %d declined (side switched off: %s).",
m_refusedClosed, m_refusedNoAtr, m_refusedSide, EnumToString(m_allowed));
//--- HOW OFTEN THE MANAGEMENT MODEL ACTUALLY ACTED. A model with a held-out AUC of 0.68 that
//--- changes nothing in the P&L is either not being asked, or asked and always saying "hold",
//--- or acting where the payoff is symmetric - three different problems with three different
//--- fixes, and without these two numbers they are indistinguishable from each other.
if(m_fadeAt > 0)
PrintFormat("CWarriorVote: FADE at >= %d agreeing - inverted %d bar(s), stood aside on %d.",
m_fadeAt, m_faded, m_fadeStood);
if(m_mgCut > 0.0)
PrintFormat("CWarriorVote: MANAGEMENT - model %s, asked at %d crossing(s), closed %d early"
" (%.1f%%), cut %.2f.",
(m_mgmt.Ready() ? StringFormat("ready (AUC %.3f)", m_mgmt.AUC()) : "NOT ready"),
m_mgAsked, m_mgExited,
(m_mgAsked > 0 ? 100.0 * m_mgExited / m_mgAsked : 0.0), m_mgCut);
}
//+------------------------------------------------------------------+
//| THE SELF-RANKING PASS - what the database was built for. |
//| |
//| For every filter, for every pattern it can express, count how its |
//| Buy and Sell firings resolved and turn that into a weight. The |
//| arithmetic is restored from ac57a72 unchanged; what had been lost |
//| was not the formula but the CALL - ApplyPatternWeight() sat in |
//| this repo overridden by three modules and invoked by none, and |
//| the firing rows carried no outcome for it to read. |
//| |
//| WHY A POOL PRIOR. A pattern with 100 firings at 58% and one with |
//| 100,000 at 52% are not equally believable, and raw wins/total |
//| treats them identically. Each pattern is shrunk toward the mean |
//| of its OWN module by MIN_TRADES_FOR_WIN_RATE pseudo-firings, so a |
//| pattern measured at exactly the minimum lands half on the pool |
//| and half on itself, and the pull halves again with every doubling |
//| of its sample. A lucky rare pattern cannot out-rank a common one |
//| on noise alone. |
//| |
//| NO LOOKAHEAD, BY CONSTRUCTION - and it is worth being precise |
//| about why, because "the database knows the future" is the obvious |
//| objection. Two independent guards: |
//| * a firing row is INSERTED only once it has resolved, so every |
//| row that exists resolved before now; and |
//| * the count is cut off at nowKey, so a row stamped later than |
//| this moment cannot enter the sum even if one existed. |
//+------------------------------------------------------------------+
#define WARRIOR_MIN_FIRINGS 100 // below this a pattern has no win rate, only a coincidence
#define WARRIOR_NO_DATA -1
//--- HOW MUCH EXPECTANCY IS A FULL VOTE. weight = 50 + 50*(R / scale), so at 0.5 a pattern earning
//--- +0.5R per firing votes 100 and one losing 0.5R votes 0, with breakeven at 50. Set from the
//--- spread actually observed rather than from taste: on EURUSD D1 with an ATR2 stop and ATR4
//--- target, pattern mean R runs roughly -0.35..+0.25, so 0.5 keeps the useful range off both rails.
#define WARRIOR_R_SCALE 0.5
void CWarriorVote::Rerank(CDatabaseManager *dbm)
{
if(dbm == NULL)
return;
MqlDateTime nt;
TimeToStruct(TimeCurrent(), nt);
const long nowKey = ((((long)nt.year*100 + nt.mon)*100 + nt.day)*100 + nt.hour)*100 + nt.min;
const int total = m_filters.Total();
int patternsSet = 0, ranked = 0, regimeCells = 0;
//--- The regime AS OF THE CLOSED BAR, read once: every pattern is asked about the same present.
const int regimeNow = RegimeCode(1);
for(int i = 0; i < total; i++)
{
CExpertSignal *filter = m_filters.At(i);
if(filter == NULL)
continue;
//--- Only our modules can be ranked: a stdlib CExpertSignal names no pattern, so it wrote no
//--- rows and there is nothing to count. This is the whole reason the classics were restored.
CWarriorSignal *ours = dynamic_cast<CWarriorSignal *>(filter);
if(ours == NULL)
continue;
const string id = ours.FilterID();
const int pc = ours.PatternCount();
if(id == "" || id == "?" || pc <= 0)
continue;
//--- THE MODULE'S OWN POOL, in R. Each pattern is shrunk toward the module it belongs to, so
//--- a pattern with a thin sample inherits its module's behaviour instead of asserting its
//--- own noise. Two aggregates per table, computed inside SQLite - no rows materialise.
//--- FETCHED ONCE, USED TWICE. The pool and the per-pattern score need the same numbers, and
//--- querying them separately doubled the work: at ~400 aggregates a day over sixteen years
//--- that was 2.4M round trips a run, and it showed - 90s became 140s.
int nB[], nS[];
double rB[], rS[];
ArrayResize(nB, pc); ArrayResize(nS, pc);
ArrayResize(rB, pc); ArrayResize(rS, pc);
ArrayInitialize(nB, 0); ArrayInitialize(nS, 0);
ArrayInitialize(rB, 0.0); ArrayInitialize(rS, 0.0);
int poolN = 0;
double poolSum = 0.0;
for(int j = 0; j < pc; j++)
{
if(ours.IsConfirmation(j))
continue; // never recorded, so never ranked
const string pat = "Pattern_" + IntegerToString(j);
dbm.FetchExpectancy(id + "_" + pat + "_Buy", nowKey, nB[j], rB[j]);
dbm.FetchExpectancy(id + "_" + pat + "_Sell", nowKey, nS[j], rS[j]);
poolN += nB[j] + nS[j];
poolSum += rB[j] * nB[j] + rS[j] * nS[j];
}
if(poolN <= 0)
continue;
const double poolR = poolSum / poolN;
bool any = false;
for(int j = 0; j < pc; j++)
{
//--- KEEPS ITS AUTHORED WEIGHT. A confirmation pattern ships at 10 so it can add
//--- confluence without ever triggering alone; letting the journal lift it to ~50 is a
//--- five-fold amplification of exactly the wrong thing.
if(ours.IsConfirmation(j))
continue;
const string pat = "Pattern_" + IntegerToString(j);
//--- REGIME-CONDITIONAL FIRST, all-regime as the fallback.
//---
//--- A pattern's unconditional record mixes the conditions it was good in with the ones it
//--- was bad in. That is the most likely reason every module measured out at a coin flip
//--- with a 2.6-point spread: a trend-follower earns in a trend and gives it back in a
//--- range, and the average of the two is nothing. Asking "how did THIS pattern do when
//--- the market looked like it does NOW" is a different and much sharper question.
//---
//--- The cost is sample: three regimes divide every cell by roughly three, and a cell below
//--- the floor has no estimate at all. So this is a HIERARCHY, not a replacement - the
//--- regime cell is used when it has the evidence, and the pattern's whole record when it
//--- does not. Nothing is ever scored on fewer observations than before.
int nbR = 0, nsR = 0;
double rbR = 0.0, rsR = 0.0;
dbm.FetchExpectancy(id + "_" + pat + "_Buy", nowKey, nbR, rbR, regimeNow);
dbm.FetchExpectancy(id + "_" + pat + "_Sell", nowKey, nsR, rsR, regimeNow);
const bool haveRegime = ((nbR + nsR) >= WARRIOR_MIN_FIRINGS);
const int nb = haveRegime ? nbR : nB[j];
const int ns = haveRegime ? nsR : nS[j];
const double rb = haveRegime ? rbR : rB[j];
const double rs = haveRegime ? rsR : rS[j];
if(haveRegime)
regimeCells++;
const int n = nb + ns;
//--- A side with no evidence contributes nothing rather than a zero: averaging a sentinel
//--- would quietly halve any pattern that only ever fires one way.
if(n < WARRIOR_MIN_FIRINGS)
continue;
const double meanR = (rb * nb + rs * ns) / n;
//--- Empirical-Bayes toward the module pool, WARRIOR_MIN_FIRINGS pseudo-firings of it, so
//--- a pattern measured at exactly the minimum lands half on the pool and half on itself
//--- and the pull halves again with every doubling of its sample.
const double shrunk = (meanR * n + poolR * WARRIOR_MIN_FIRINGS) /
(n + (double)WARRIOR_MIN_FIRINGS);
//--- EXPECTANCY -> A 0..100 VOTE. Breakeven is R = 0, and that maps to 50 - the same place
//--- a coin flip sat under the win-rate map that survived measurement. WARRIOR_R_SCALE
//--- says how much R it takes to reach a full-throated vote.
//---
//--- Gentle on purpose. Centring the OLD win-rate map so a coin flip voted nothing took
//--- this run from +99.98 (55 trades, PF 1.18) to -89.28 (22 trades, PF 0.70): the
//--- ranking is walk-forward, so a pattern significant on the past is largely not
//--- significant on the future, and leaning hard on the measurement concentrates the
//--- portfolio into whatever most recently got lucky. Rank on expectancy - but lean
//--- gently on the ranking.
double w = 50.0 + 50.0 * (shrunk / WARRIOR_R_SCALE);
if(w < 0.0) w = 0.0;
if(w > 100.0) w = 100.0;
ours.ApplyPatternWeight(j, (int)MathRound(w));
patternsSet++;
any = true;
}
if(any)
ranked++;
}
//--- THE MODULE WEIGHT IS NOT SET HERE. It is an INPUT, swept by the MT5 optimiser alongside the
//--- threshold and the ATR multiples - see WeightOf() in the EA. Two reasons it belongs there
//--- and the pattern weights belong here: there are 49 patterns and only 12 modules, so the
//--- fine grain is impossible to optimise and the coarse grain is easy; and a module weight
//--- derived from the same journal as its own patterns would count the same evidence twice.
//--- Measured before this split: module weights from the journal moved every module to within
//--- 1.5 points of the same number, which rescaled the whole vote without reordering anything.
if(ranked > 0)
PrintFormat("CWarriorVote: reranked %d module(s), %d pattern weight(s) from expectancy;"
" regime %d, %d of them on regime-specific evidence.",
ranked, patternsSet, regimeNow, regimeCells);
}
//+------------------------------------------------------------------+
//| The crossing bar's state. Reads bar `shift` and older - never |
//| newer - so a row built here could have been built live. |
//+------------------------------------------------------------------+
bool CWarriorVote::MgmtFeatures(double &x[], const int shift, const int barsToCross,
const double maeBeforeR, const bool isLong)
{
ArrayResize(x, MGMT_FEATURES);
ArrayInitialize(x, 0.0);
const double atr = m_atr.Main(shift);
if(atr <= 0.0 || !MathIsValidNumber(atr) || barsToCross <= 0)
return false;
const double h = High(shift), l = Low(shift), c = Close(shift);
const double rng = h - l;
if(rng <= 0.0)
return false;
double v20 = 0.0;
for(int k = 0; k < 20; k++)
v20 += (double)TickVolume(shift + k);
v20 /= 20.0;
const double vNow = (double)TickVolume(shift);
int i = 0;
//--- Bounded, so one slow grind cannot dominate the input scale.
x[i++] = MathMin(barsToCross, MGMT_HORIZON) / (double)MGMT_HORIZON;
x[i++] = MathMin(maeBeforeR, 2.0) / 2.0;
x[i++] = MathMin(MGMT_TRIGGER_R / barsToCross, 1.0);
x[i++] = EfficiencyRatio(shift, 20);
x[i++] = MathMin(VarianceRatio(shift, 60, 5), 3.0) / 3.0;
x[i++] = RegimeCode(shift) / 2.0;
x[i++] = MathMin(rng / atr, 5.0) / 5.0;
//--- Where the bar closed IN ITS OWN RANGE, oriented to the trade: 1 means it closed at the
//--- extreme in our favour (extension), 0 means it gave the bar back (exhaustion). Orienting it
//--- is what lets one model serve both sides.
x[i++] = isLong ? ((c - l) / rng) : ((h - c) / rng);
x[i++] = (v20 > 0.0) ? MathMin(vNow / v20, 3.0) / 3.0 : 0.0;
x[i++] = isLong ? 1.0 : 0.0;
return (i == MGMT_FEATURES);
}
//+------------------------------------------------------------------+
//| TRAIN ON EVERY VIRTUAL TRADE HISTORY OFFERS. |
//| |
//| One trade is simulated from every bar, in BOTH directions, under |
//| the EA's own stop and target. A row is emitted only when the |
//| trade both CROSSED the trigger and later RESOLVED - a crossing |
//| with no answer is not a training example, and a trade that never |
//| crossed is not one either because the question was never asked. |
//| |
//| Both directions on purpose: it doubles the sample and the model |
//| carries `is_long`, so the asymmetry is something it can learn |
//| rather than something split across two half-sized models. |
//| |
//| Deferred like the neural module's fit, and for the same reason - |
//| Bars() at OnInit is near-zero in the tester, so training there |
//| can never work, while training partway through means every fit |
//| saw only its own past. |
//+------------------------------------------------------------------+
void CWarriorVote::TrainManagementIfDue(void)
{
if(m_mgCut <= 0.0 || m_symbol == NULL)
return;
const datetime bar = iTime(m_symbol.Name(), m_period, 0);
if(bar == m_mgLastTrain)
return;
m_mgLastTrain = bar;
const int bars = Bars(m_symbol.Name(), m_period);
if(bars < m_mgMinBars)
return;
//--- RETRAIN ON A CADENCE, LIKE THE NEURAL MODULE DOES - NOT ONCE.
//---
//--- The first version fitted at bar ~750 (early 2015, on the deepened pre-2015 history) and then
//--- used that model unchanged through 2026. Its held-out AUC - 0.77 on USDJPY - was measured on
//--- an early-2015 validation tail, and eleven years later it produced no P&L effect on any
//--- symbol (pooled t=0.38). Skill measured once and never refreshed is skill that decays out
//--- from under the run. Each refit sees only its own past, so this stays walk-forward; the AUC
//--- line printed on every refit is what says whether the skill persists across eras or was a
//--- property of one.
if(m_mgTrained && (m_mgRetrainBars <= 0 || bars < m_mgTrainedAtBars + m_mgRetrainBars))
return;
m_mgTrainedAtBars = bars;
const int want = MathMin(bars, WARRIOR_NET_HISTORY);
if(!DeepenPrices(want) || (want > m_atr.BufferSize() && !m_atr.BufferResize(want)))
return;
//--- NEVER WALK PAST THE BUFFER. `want` caps at WARRIOR_NET_HISTORY, so on H4 (18,000+ bars) the
//--- series only holds the newest 8,192 - every read beyond that returns 0.0 in SILENCE, and the
//--- loop burns thousands of iterations producing nothing. The span is the smaller of "history
//--- that exists" and "history that is readable".
const int first = 70;
const int last = MathMin(bars - MGMT_HORIZON - 2, want - 70);
if(last - first < 300)
return;
CMatrixDouble xy((last - first) * 2, MGMT_FEATURES + 1);
int rows = 0, crossed = 0;
double x[];
for(int e = last; e >= first; e--) // oldest entry bar to newest
{
const double atrE = m_atr.Main(e);
if(atrE <= 0.0 || !MathIsValidNumber(atrE))
continue;
const double entry = Close(e);
const double risk = m_stopAtr * atrE;
const double tgt = (m_targetAtr > 0.0 ? m_targetAtr : m_stopAtr) * atrE;
if(risk <= 0.0 || entry <= 0.0)
continue;
for(int d = 0; d < 2; d++)
{
const bool isLong = (d == 0);
double mae = 0.0, maeAtCross = 0.0, maeBefore = 0.0;
int crossBar = -1, crossBars = 0;
int label = -1;
for(int k = 1; k <= MGMT_HORIZON; k++)
{
const int sh = e - k; // forward in time = smaller shift
if(sh < 1)
break;
const double hi = High(sh), lo = Low(sh);
const double fav = isLong ? (hi - entry) : (entry - lo);
const double adv = isLong ? (entry - lo) : (hi - entry);
if(adv > mae)
mae = adv;
//--- STOP FIRST on a bar that touched both: bar data cannot order them, and resolving
//--- ties in our own favour reports a model no live account could reproduce.
if(adv >= risk)
{ label = (crossBar >= 0) ? 0 : -1; break; }
if(crossBar < 0 && fav >= MGMT_TRIGGER_R * risk)
{
//--- THE FEATURES DESCRIBE THE BAR BEFORE THE CROSSING, NOT THE CROSSING BAR.
//---
//--- The touch happens somewhere inside bar `sh`, but that bar's close and range are
//--- only known when it ENDS - after the touch. Whether it went on to close at its
//--- extreme (extension) or mid-range (exhaustion) is the answer, not the question,
//--- and the first version of this fed it to the model: held-out AUC 0.66-0.71 that
//--- then LOST money when acted on live, where bar 0 is still forming and only bar 1
//--- can be read. Train on what live can see or the AUC is a number about the leak.
//---
//--- Same for the adverse excursion: `maeBefore` stops at the previous bar, because
//--- the crossing bar's low may also be after the touch.
crossBar = sh + 1;
crossBars = k;
maeAtCross = maeBefore;
}
maeBefore = mae; // carried one bar behind, for the reason above
if(fav >= tgt)
{ label = (crossBar >= 0) ? 1 : -1; break; }
}
if(crossBar < 0 || label < 0)
continue; // never asked, or never answered
crossed++;
if(!MgmtFeatures(x, crossBar, crossBars, maeAtCross / risk, isLong))
continue;
bool ok = true;
for(int f = 0; f < MGMT_FEATURES; f++)
if(!MathIsValidNumber(x[f]))
{ ok = false; break; }
if(!ok)
continue;
for(int f = 0; f < MGMT_FEATURES; f++)
xy.Set(rows, f, x[f]);
xy.Set(rows, MGMT_FEATURES, (double)label);
rows++;
}
}
if(rows < 300)
{
PrintFormat("CWarriorVote: management model - only %d usable crossing(s) of %d - not trained.",
rows, crossed);
m_mgTrained = true; // do not re-walk history every bar
return;
}
string names[];
ArrayResize(names, MGMT_FEATURES);
for(int f = 0; f < MGMT_FEATURES; f++)
names[f] = CManagementNet::FeatureName(f);
if(m_mgmt.Train(xy, rows, names))
PrintFormat("CWarriorVote: management model trained on %d crossing(s).", rows);
else
PrintFormat("CWarriorVote: management model refused - %s", m_mgmt.Why());
m_mgTrained = true;
}
//+------------------------------------------------------------------+
//| ASK ONCE, AT THE CROSSING. The latch is per ticket and the answer |
//| is remembered, so the model is not re-polled every tick with a |
//| drifting input - one trade, one decision. |
//+------------------------------------------------------------------+
bool CWarriorVote::MgmtDecide(const bool isLong)
{
if(m_mgCut <= 0.0 || !m_mgmt.Ready() || m_symbol == NULL)
return false;
if(!PositionSelect(m_symbol.Name()))
{ m_mgTicket = 0; return false; }
const ulong ticket = (ulong)PositionGetInteger(POSITION_TICKET);
if(ticket != m_mgTicket)
{
m_mgTicket = ticket;
m_mgEntry = PositionGetDouble(POSITION_PRICE_OPEN);
const double sl = PositionGetDouble(POSITION_SL);
m_mgRisk = (sl > 0.0) ? MathAbs(m_mgEntry - sl) : 0.0;
m_mgMae = 0.0;
m_mgBars = 0;
m_mgCrossed = false;
m_mgExit = false;
}
if(m_mgExit)
return true; // already decided; keep saying so until closed
if(m_mgCrossed || m_mgRisk <= 0.0)
return false;
const double price = isLong ? m_symbol.Bid() : m_symbol.Ask();
const double fav = isLong ? (price - m_mgEntry) : (m_mgEntry - price);
const double adv = isLong ? (m_mgEntry - price) : (price - m_mgEntry);
if(adv > m_mgMae)
m_mgMae = adv;
m_mgBars = (int)MathMax(1, iBarShift(m_symbol.Name(), m_period,
(datetime)PositionGetInteger(POSITION_TIME)));
if(fav < MGMT_TRIGGER_R * m_mgRisk)
return false;
m_mgCrossed = true;
double x[];
if(!MgmtFeatures(x, 1, m_mgBars, m_mgMae / m_mgRisk, isLong))
return false;
const double p = m_mgmt.Score(x);
if(p < 0.0)
return false;
m_mgAsked++;
m_mgExit = (p < m_mgCut);
if(m_mgExit)
m_mgExited++;
return m_mgExit;
}
//+------------------------------------------------------------------+
bool CWarriorVote::CheckCloseLong(double &price)
{
if(MgmtDecide(true))
{
price = m_symbol.Bid();
return true;
}
return CWarriorSignal::CheckCloseLong(price);
}
//+------------------------------------------------------------------+
bool CWarriorVote::CheckCloseShort(double &price)
{
if(MgmtDecide(false))
{
price = m_symbol.Ask();
return true;
}
return CWarriorSignal::CheckCloseShort(price);
}
#endif // WARRIOR_SIMPLE_VOTE_MQH