//+------------------------------------------------------------------+ //| 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(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(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