//+------------------------------------------------------------------+ //| Warrior_EA | //| AnimateDread | //| | //+------------------------------------------------------------------+ #include #include "..\System\NewBar.mqh" #include "..\Structures\tradeRecordStructure.mqh" #include "..\Structures\signalInfoStructure.mqh" #include "..\Variables\ConfidenceBridge.mqh" #include "..\System\TradeChecks.mqh" #include "..\System\BinomialStats.mqh" //--- Enumerations #include "..\Enumerations\GlobalEnums.mqh" //+------------------------------------------------------------------+ //| SIGNAL ARROW NAMESPACE - declared HERE, in the common base, and | //| not in ExpertSignalAIBase.mqh where it used to live. | //+------------------------------------------------------------------+ #ifndef SIG_ARROW_PREFIX #define SIG_ARROW_PREFIX "WarSig_" #endif //--- THE FILTERED VIEW's own namespace: the combined vote, which belongs to no single filter. Sits //--- under the same bare prefix as the per-filter arrows so one purge still reaches everything. #define SIG_VOTE_PREFIX SIG_ARROW_PREFIX "VOTE_" //--- SIGNAL MARKS ARE TWO OBJECTS, drawn as a pair for two different reading distances (2026-08-20 //--- user request). The LINE is a short horizontal segment at the trigger price - the precise //--- entry/ exit level, readable only zoomed in. #define WARRIOR_SIG_BUY_COLOR clrDodgerBlue #define WARRIOR_SIG_SELL_COLOR clrRed //--- THE COLOUR IS THE DIRECTION ENCODING, not decoration - a signal line carries no arrow code, so //--- SaveChartSignals recovers buy-vs-sell by comparing against WARRIOR_SIG_BUY_COLOR. Half-width //--- of the segment as a fraction of one bar. #define WARRIOR_SIG_LEVEL_HALF_SPAN 1.3 //--- Wingdings codes for the arrow half of the mark, and the direction token persisted in the //--- .arrows sidecar - one number doing both jobs, as it originally did. The sidecar stores it, the //--- line half recovers direction from its COLOUR (it carries no code), and the arrow half draws it. #define WARRIOR_SIG_CODE_BUY 217 #define WARRIOR_SIG_CODE_SELL 218 //--- How far back either chart rebuild reaches: the AI members' "Show signals" rescan and the //--- aggregate's historical filtered overlay. #ifndef SIGNAL_RESCAN_LOOKBACK_BARS #define SIGNAL_RESCAN_LOOKBACK_BARS 5000 #endif //--- Panel "Hide signals" toggle (Warrior_EA.mq5). Read when creating an arrow so one drawn while the //--- toggle is off is born hidden rather than flashing onto the chart until the next sweep. extern bool g_signalsVisible; //--- The arrow half's object name is the line's plus this suffix, so it stays inside SIG_ARROW_PREFIX //--- and every prefix-scoped purge, sidecar scan and visibility sweep already reaches it unchanged. #define WARRIOR_SIG_ARROW_SUFFIX "_a" string WarriorSignalArrowName(const string lineName) { return lineName + WARRIOR_SIG_ARROW_SUFFIX; } //+------------------------------------------------------------------+ //| Removes a signal mark - BOTH halves. Every caller that used to | //| ObjectDelete the line name must come through here, or the arrow | //| outlives the line it belongs to and the chart accumulates marks | //| for signals that were withdrawn. | //+------------------------------------------------------------------+ void WarriorDeleteSignalMark(const string name) { ObjectDelete(0, name); ObjectDelete(0, WarriorSignalArrowName(name)); } //+------------------------------------------------------------------+ //| The one place a signal mark is actually created. Deliberately a | //| free function rather than a method: four unrelated callers need | //| it (a classic filter, the aggregate signal's vote layer, its | //| historical overlay rebuild, and the AI members' own raw view) | //| and only some of them are signal objects at all. | //+------------------------------------------------------------------+ void WarriorPlotSignalLevel(const string name, const datetime t, const ENUM_TIMEFRAMES period, const double price, const bool isBuy, const bool isTrade, const string tooltip) { if(t <= 0 || !MathIsValidNumber(price) || price <= 0.0) return; int half = (int)(PeriodSeconds(period) * WARRIOR_SIG_LEVEL_HALF_SPAN); if(half <= 0) half = 60; ObjectCreate(0, name, OBJ_TREND, 0, t - half, price, t + half, price); //--- Re-applied every call, not just at creation: this doubles as the refresh path, and a mark //--- whose price moved (a redraw at a corrected level) must move with it. ObjectSetInteger(0, name, OBJPROP_TIME, 0, t - half); ObjectSetDouble(0, name, OBJPROP_PRICE, 0, price); ObjectSetInteger(0, name, OBJPROP_TIME, 1, t + half); ObjectSetDouble(0, name, OBJPROP_PRICE, 1, price); //--- A trend line rays to infinity by default - that would paint the whole chart. ObjectSetInteger(0, name, OBJPROP_RAY_LEFT, false); ObjectSetInteger(0, name, OBJPROP_RAY_RIGHT, false); ObjectSetInteger(0, name, OBJPROP_COLOR, isBuy ? WARRIOR_SIG_BUY_COLOR : WARRIOR_SIG_SELL_COLOR); //--- Thicker on both layers for the same reason the span grew (2026-08-19): a 1px dotted dark //--- line on a candle chart is invisible at any realistic zoom. The trade layer stays the //--- heavier of the two so the ranking still reads at a glance. ObjectSetInteger(0, name, OBJPROP_WIDTH, isTrade ? 3 : 2); ObjectSetInteger(0, name, OBJPROP_STYLE, isTrade ? STYLE_SOLID : STYLE_DOT); //--- Not selectable: these are readouts, and a chart carrying thousands of them becomes //--- unusable if a stray drag can pick one up and move it. ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false); ObjectSetInteger(0, name, OBJPROP_HIDDEN, true); ObjectSetInteger(0, name, OBJPROP_BACK, !isTrade); // opinions behind the candles, trades in front ObjectSetInteger(0, name, OBJPROP_TIMEFRAMES, g_signalsVisible ? OBJ_ALL_PERIODS : OBJ_NO_PERIODS); ObjectSetString(0, name, OBJPROP_TOOLTIP, tooltip); //--- THE FINDER HALF. Anchored to the candle's extreme rather than the trigger price so it //--- clears the body at every zoom - the whole point is to be visible when the line is not. string an = WarriorSignalArrowName(name); int shift = iBarShift(_Symbol, period, t, true); double anchorPrice = price; if(shift >= 0) anchorPrice = isBuy ? iLow(_Symbol, period, shift) : iHigh(_Symbol, period, shift); if(!MathIsValidNumber(anchorPrice) || anchorPrice <= 0.0) anchorPrice = price; ObjectCreate(0, an, OBJ_ARROW, 0, t, anchorPrice); ObjectSetInteger(0, an, OBJPROP_TIME, 0, t); ObjectSetDouble(0, an, OBJPROP_PRICE, 0, anchorPrice); ObjectSetInteger(0, an, OBJPROP_ARROWCODE, isBuy ? WARRIOR_SIG_CODE_BUY : WARRIOR_SIG_CODE_SELL); //--- ANCHOR is what keeps the glyph OUTSIDE the candle: its top pinned to the low hangs it below, //--- its bottom pinned to the high stands it above. Anchoring the centre would bury it in the wick. ObjectSetInteger(0, an, OBJPROP_ANCHOR, isBuy ? ANCHOR_TOP : ANCHOR_BOTTOM); ObjectSetInteger(0, an, OBJPROP_COLOR, isBuy ? WARRIOR_SIG_BUY_COLOR : WARRIOR_SIG_SELL_COLOR); ObjectSetInteger(0, an, OBJPROP_WIDTH, isTrade ? 2 : 1); ObjectSetInteger(0, an, OBJPROP_SELECTABLE, false); ObjectSetInteger(0, an, OBJPROP_HIDDEN, true); ObjectSetInteger(0, an, OBJPROP_BACK, !isTrade); ObjectSetInteger(0, an, OBJPROP_TIMEFRAMES, g_signalsVisible ? OBJ_ALL_PERIODS : OBJ_NO_PERIODS); ObjectSetString(0, an, OBJPROP_TOOLTIP, tooltip); } //--- THE VOTE READOUT's own object namespace - now unused (the readout moved into the status //--- label, see g_liveVoteLine), kept because WarriorChartPrefixes() still purges it defensively //--- for a chart that has an old build's objects on it. #define VOTE_HUD_PREFIX "WarriorVoteHUD" //--- THE LIVE AGGREGATED VOTE, one line, refreshed every tick/timer by UpdateVoteReadout() and //--- read by both status-label builders (PublishEnsembleStatus, CExpertSignalAIBase::PublishStatus) //--- so there is exactly one place on the chart showing what the combined vote is doing right now - //--- no more separate top-right HUD, and no more per-member breakdown next to it (user request //--- 2026-08-24: "a single line... without telling me each individual network"). string g_liveVoteLine = ""; //+------------------------------------------------------------------+ //| ONE PREDICATE FOR "ARE THE MODELS ON THIS CHART DEPLOYED?" | //| | //| It exists because the panel contradicted itself (user report | //| 2026-08-25): every member line read "Live - learning from new | //| bars" while the aggregate line under them read "training, not | //| tradable yet". Neither was lying - they were answering DIFFERENT | //| questions. The member word came from m_trainingComplete; the | //| aggregate word came from whether the number had been produced by | //| ProspectiveVote() rather than by a real Direction() call, which | //| happens on any bar where every member abstains and says nothing | //| whatsoever about training state. | //| | //| A readout that can disagree with itself is worse than one that is | //| wrong, because there is no way to tell which half to believe. So | //| both now resolve through here: members publish their own state, | //| and every consumer asks this one question of the board. | //+------------------------------------------------------------------+ #define WARRIOR_MODEL_SLOTS 8 bool g_warriorModelSlotUsed[WARRIOR_MODEL_SLOTS]; bool g_warriorModelConverged[WARRIOR_MODEL_SLOTS]; //--- A member writes ONLY its own slot, never reads another's - same split as the vote board in //--- Variables\ConfidenceBridge.mqh, and for the same reason (see its header note on last-writer-wins). void PublishModelConverged(const int slot, const bool converged) { if(slot < 0 || slot >= WARRIOR_MODEL_SLOTS) return; g_warriorModelSlotUsed[slot] = true; g_warriorModelConverged[slot] = converged; } //--- How many models have published, and how many of those call themselves converged. void WarriorModelCensus(int &published, int &converged) { published = 0; converged = 0; for(int i = 0; i < WARRIOR_MODEL_SLOTS; i++) if(g_warriorModelSlotUsed[i]) { published++; if(g_warriorModelConverged[i]) converged++; } } //--- DEPLOYED means EVERY published model is converged, not "at least one". A half-trained ensemble //--- still votes, and its vote is diluted by the members that abstain while they train - calling that //--- deployed would present a number measured on a different set of models than the one trading. bool WarriorChartModelsDeployed(void) { int published = 0, converged = 0; WarriorModelCensus(published, converged); return (published > 0 && converged == published); } //--- Overlay declustering window, in bars - same default as the per-member arrows' //--- m_signalClusterWindow. A root-level constant rather than a borrowed member because the root //--- has no AI state and the two layers may legitimately diverge later. #define OVERLAY_NMS_WINDOW 6 //--- The one resolution point for the Trade direction input. Every gate - live entry, //--- reconstruction, HUD verdict - resolves through here so they cannot drift apart. TRADING_DIRECTION WarriorEffectiveDirection(void) { return tradingdirection; } bool WarriorDirectionAllows(const bool isLong) { TRADING_DIRECTION d = WarriorEffectiveDirection(); return isLong ? (d != SHORT_ONLY) : (d != LONG_ONLY); } //+------------------------------------------------------------------+ //| THE VOTE NORMALIZATION RULE, and the only copy of it. | //| | //| Live and replay had one each, and they drifted - the replay used | //| ModuleWeight() where live used VoteCapableWeight(), so a META | //| head (a gate, structurally unable to agree) sat in the replay's | //| divisor shrinking every reconstructed vote. Both now Add() here. | //| | //| The rule, in one place: | //| - CAPABLE weight always enters the divisor, contribution or not. | //| An abstainer LOOKED and said nothing, and diluting the | //| consensus is exactly what it should do. | //| - a member that could not look at all (no era-end snapshot, not | //| yet trained, a gate) contributes NO capable weight and so is | //| not in the divisor - the caller simply never Add()s it. | //| - only a non-zero contribution counts as a VOTER, which is what | //| the readout's "N voter(s)" means. | //| | //| WHY NOT CExpertSignal::Direction()'s divisor. Not because the | //| stdlib ignores m_weight - it does not. Each signal weights its | //| OWN conditions (m_weight*(Long-Short)) and every child applies | //| its own in turn, so the weight is respected end to end. The ONE | //| divergence is the normalizer: stdlib divides by the COUNT of | //| participating filters, we divide by the summed CAPABLE weight. | //| | //| Both scale identically with agreement, so stdlib's is a perfectly | //| valid RELATIVE consensus measure. What it is not is an ABSOLUTE | //| one: its output scale is the mean module weight, and we re-derive | //| that from held-out win rates every era. Measured on USDJPY across | //| five eras, the mean module weight ran 0.162 -> 0.285 - a 76% | //| swing - so under /count every vote would have risen 76% with no | //| change in agreement or accuracy, and a fixed threshold would mean | //| something different each era. Dividing by capable weight cancels | //| that factor, which is what makes the result a WIN RATE the | //| threshold, the deploy gate and break-even can all be compared to. | //| | //| The stdlib arithmetic would be exactly right with m_weight left | //| at its 1.0 default and the win rate carried by the PATTERN weight | //| instead - see project_direction_is_a_transaction. That is a live | //| semantic change, not a refactor, so it is not done here. | //+------------------------------------------------------------------+ struct SVoteAccumulator { double num; // signed sum of contributions double capable; // divisor: total capable weight, abstainers included int voters; // members that actually took a side SVoteAccumulator(void) { Reset(); } void Reset(void) { num = 0.0; capable = 0.0; voters = 0; } void Add(const double contribution, const double capableWeight) { capable += capableWeight; if(contribution == 0.0) return; // abstention: dilutes the consensus, is not a voter num += contribution; voters++; } double Net(void) const { return (capable > 0.0) ? (num / capable) : 0.0; } }; //--- The symbol's own trading-session table, asked two questions (2026-08-19 user request: //--- "everything will be dynamic and self adapting to DST"). Is `now` (server time) inside any //--- trading session of its weekday? bool WarriorMarketOpenNow(const string symbol, const datetime now) { MqlDateTime dt; TimeToStruct(now, dt); int secOfDay = dt.hour * 3600 + dt.min * 60 + dt.sec; datetime from = 0, to = 0; for(uint s = 0; SymbolInfoSessionTrade(symbol, (ENUM_DAY_OF_WEEK)dt.day_of_week, s, from, to); s++) { if(secOfDay >= (int)from && secOfDay < (int)to) return true; } return false; } //--- The LAST session close of the given weekday, in seconds from that day's midnight (86400 on //--- symbols that trade to midnight). -1 = no trading that day. int WarriorMarketCloseSeconds(const string symbol, const int dayOfWeek) { datetime from = 0, to = 0; int lastTo = -1; for(uint s = 0; SymbolInfoSessionTrade(symbol, (ENUM_DAY_OF_WEEK)dayOfWeek, s, from, to); s++) lastTo = (int)to; return lastTo; } // #define MAX_TABLE_ROWS 1000 // default row cap before the oldest entry is pruned; the live // value comes from the DB_MaxRowsPerTable input via // MaxTableRows() - raised for meta-label corpus builds #define MIN_TRADES_FOR_WIN_RATE 100 // minimum sample size before a pattern's win rate is trusted #define NO_DATA_WIN_RATE -1 // sentinel: not enough trades to compute a win rate //--- Hard floor on SL distance from entry, as an ATR multiple. Pure sanity net: the broker's own //--- SYMBOL_TRADE_STOPS_LEVEL is enforced separately and precisely by TCAdjustStops() further down. #define MIN_SL_ATR_MULTIPLIER 0.5 //--- The SL_PREV_SWING / TP_PREV_SWING sentinels (-101) were REMOVED 2026-07-31 along with every //--- other swing anchor on SL and TP - see STOP_LOSS_MODE in Enumerations\InputEnums.mqh. //--- The SL_INTELLIGENT_MODE / TP_INTELLIGENT_MODE / ENTRY_INTELLIGENT_MODE sentinels and their four //--- tuning constants went 2026-08-25 with the confidence-scaled trade management. m_sl_mode and //--- m_tp_mode are now UNCONDITIONALLY literal ATR multiples and m_entry_multiplier an unconditional //--- signed ATR offset - which is what lets OpenParams() read them straight through with no sentinel //--- test, and what makes a tester GA sweep of them mean exactly what it appears to mean. //--- ENTRY_MULTIPLIER's "Prev swing" sentinel (-101), still declared for the commented-out enum member. #define ENTRY_PREV_SWING_MODE (-101) // class CExpertSignalCustom : public CExpertSignal { private: void DeleteOldestEntry(string tableName); //--- (CheckForDuplicateTrade / FindLastTradeIndex / UpdateTradeStatusAndExit were declared here but //--- never defined anywhere and never called - removed. Nothing linked against them; they only made //--- it look as though duplicate-trade detection existed on this class.) void UpdateTradeRecordInDatabase(string tableName, TradeRecord &tradeRecord); void ProcessSignal(SignalInfo &signal); void BufferSignal(SignalInfo &signal); bool CheckClosePosition(bool isLong, double &price); bool CheckOpenPosition(bool isLong, double &price, double &sl, double &tp, datetime &expiration); bool ShouldTraceTradeRejections(void) const; //--- Mirrors CExpertTrade::Buy()/Sell()'s own price-vs-stops-level decision so OpenParams() can //--- validate the stops against the order type the trade layer is actually going to send. ENUM_ORDER_TYPE ResolveOrderType(bool isLong, double price); void BufferNewTickSignal(string filterID, string pattern, string bias, const MqlDateTime& brokerTime, double entryPrice, double netVote); string PatternName(int patternIndex) { return "Pattern_" + IntegerToString(patternIndex); } SignalInfo signalBuffer[]; protected: //--- protected (not private): CExpertSignalAIBase's pattern-database backfill (Expert\AIBase\ //--- OnlineLearning.mqh) calls both directly, so the training-time path can journal into the exact //--- same tables/rows the live per-tick path (BufferNewTickSignal above) writes to. void RegisterSignal(int year, int month, int day, int DOW, int hour, int minutes, string tableName, string pattern, string direction, double entryPrice, double exitPrice, string result, double netVote); string PatternTableName(string filterID, string pattern, string direction); bool m_prohibition_signal; bool m_useDatabase; CiATR m_ATR; // ATR indicator string m_id; //--- m_active_pattern/m_active_direction are the SCRATCH slots the signal classes' Long/Short //--- ladders write into (last-writer-wins WITHIN one ladder is intended - it is the grading). //--- The DB journaling reads ONLY the per-side slots. string m_active_pattern; string m_active_direction; string m_active_pattern_long; // long ladder's match on the last evaluation, or "NULL" string m_active_pattern_short; // short ladder's match on the last evaluation, or "NULL" //--- This filter's own net vote, LongCondition() - ShortCondition(), in pattern-weight units //--- before m_weight scaling. Same sign as m_lastFiredDirection; journaled into the netVote //--- column as DATA, never used as a journaling filter - see the per-side journaling comment in //--- Direction(). double m_lastNetVote; //--- The two ladder results behind m_lastNetVote, kept apart from it because the net alone //--- cannot answer "at what weight". int m_lastLongWeight; int m_lastShortWeight; //--- HISTORICAL FILTERED-OVERLAY sweep state (see AdvanceFilteredOverlay). //--- VOTE-LEVEL COOLDOWN. The member-level NMS in CExpertSignalAIBase declusters each MEMBER's //--- own signal; what trades and what is drawn is the COMBINED VOTE, which had no spacing rule at //--- all - 2,970 voting bars became 222-273 arrows with nothing between them. This is that rule. //--- Live state: bar time of the last KEPT vote, plus a cached decision for the bar currently //--- being evaluated. The cache is load-bearing, not an optimisation: Direction() is a //--- TRANSACTION that journals, draws and consumes one-shot state, and it can run more than once //--- on the same bar - without this a second call would flip the bar's own verdict. datetime m_voteCoolKeptTime; datetime m_voteCoolEvalTime; bool m_voteCoolEvalAccept; //--- Overlay sweep record, filled OLDEST-FIRST: the sweep decrements a SERIES index, so it walks //--- oldest -> newest (the m_overlayIndex comment used to claim the opposite and was wrong). The //--- prune reads it FORWARD, so the overlay keeps the FIRST bar of a cluster exactly as the live //--- path does. It is a second pass and not inline because the sweep is CHUNKED across ticks - //--- local state would reset at every chunk boundary and declutter nothing. datetime m_overlayVoteTime[]; bool m_overlayVoteBuy[]; int m_overlayVoteCount; bool m_overlayPending; int m_overlayIndex; // next SERIES index to process; DECREMENTS, so oldest -> newest int m_overlayStopIndex; // lowest (most recent) series index the sweep reaches //--- Bar time at which the EA took over drawing arrows itself. The sweep RECONSTRUCTS what the //--- vote would have been; forward of this the arrows are the real decision, placed by //--- CheckOpenPosition after the order parameters validated. datetime m_overlayLiveCutoff; //--- Per-sweep census, so a blank filtered view can state its own cause - see the report at the //--- end of AdvanceFilteredOverlay(). int m_overlaySweptBars; //--- Bars where at least ONE member had a snapshot entry, i.e. the divisor was non-zero. Split //--- from m_overlayVotedBars (which also requires a NON-ZERO net) because those two being one //--- number made three different blank-chart causes print the same sentence - and it printed //--- "no member has a completed era yet" while the members were on era 23 (2026-08-24). //--- hadData == 0 -> nobody has published a snapshot: a member/index/era problem //--- hadData > 0, voted == 0 -> every member looked and said Neutral: a calibration outcome //--- voted > 0, drawn == 0 -> the vote was real but never cleared the threshold int m_overlayHadDataBars; //--- Bars that DID carry a member snapshot and still had a zero divisor - i.e. every member that //--- looked was ruled no-skill (HasDemonstratedEdge). A distinct cause from "nobody published". int m_overlaySnapNoWeightBars; int m_overlayVotedBars; int m_overlayDrawn; //--- Census-log change latch (2026-08-19): the sweep completes ~once a minute and its census //--- line printed every time - ~560 near-identical lines/day. int m_overlayLastLogDrawn; double m_overlayLastLogBest; int m_overlaySkippedLogs; double m_overlayBestNet; int m_overlayVotedBuy; int m_overlayVotedSell; //--- Sweep-scoped NMS state (see the decluster block in AdvanceFilteredOverlay). Members rather //--- than locals because the sweep is chunked across timer slices; reset at every arm. int m_overlayNmsLastBuyIdx; int m_overlayNmsLastSellIdx; int m_overlayNmsKeptIdx; bool m_overlayNmsKeptBuy; double m_overlayNmsKeptNet; //--- COMBINED-VOTE REPLAY SCORE, tallied by the sweep on every bar whose reconstructed vote //--- clears the open threshold under the direction policy - the same population the era-end vote //--- scorer counts. Tallied BEFORE declustering: NMS thins the drawn arrows, not the calls the //--- vote made. Harvested once per completed sweep (TakeOverlayVoteScore) to backfill an EMPTY //--- ensemble record - a deployed ensemble runs no further eras, so without this the panel's //--- "Vote win rate" sat on "measuring..." forever after the replay pass (user report 2026-08-25). long m_overlayVoteFired; long m_overlayVoteWins; bool m_overlayScoreReady; //--- Session peak |vote|, for the readout. The single most useful number for choosing //--- Signal_ThresholdOpen: a threshold above the peak can never fire, and until this was on screen the //--- only way to learn that was to wait an era and read the gate line. double m_votePeak; //--- Live voter count from the most recent Direction() call. RefreshVoteReadout() keys on it: a //--- bar with real voters keeps its display; only a voterless bar is repainted prospectively. int m_lastLiveVoters; int m_maxTableRows; // per-table row cap, from the DB_MaxRowsPerTable input int m_pattern_count; double m_entry_multiplier; // Configurable multiple for ATR entry adjustment int m_periods; // ATR periods int m_sl_mode; // STOP_LOSS_MODE int: a literal ATR multiple below/above the entry int m_tp_mode; // TAKE_PROFIT_MODE int: a literal ATR multiple from the entry //--- HOLD-TO-BARRIER exit policy (2026-08-15, fractal-target fidelity). The deploy gate //--- certifies a win rate measured on hold-to-resolution outcomes: entry at the signal bar, then //--- the measured SL or TP decides. bool m_holdToBarrier; double m_dbConfidence; // last average normalized DB win-rate across active filters //--- Direction()'s per-second aggregation state. The window key is a full timestamp (broker //--- clock since 2026-08-19), NOT MqlDateTime.sec. datetime m_directionCurrentSecond; double m_directionAggregatedResult; int m_directionCount; double m_directionLastResult; int m_lastFiredDirection; // +1 Buy / -1 Sell / 0 none - THIS filter's own latest vote, // set in Direction() before children are added in. Unlike // GetActivePatternLong()/Short(), never consumed/reset by // a read - a pure peek, safe for a parent to poll every tick. public: CExpertSignalCustom(void); ~CExpertSignalCustom(void); virtual bool AddFilter(CExpertSignal *filter); //--- THE ADMINISTRATIVE TREE: every child this node owns. Kept as its own pair of accessors //--- (rather than callers reaching into m_filters) because indicators, ticks, panel commands and //--- trait counts must reach every child, and a child missing from them silently stops training //--- or stops answering the panel. int ChildSignalCount(void) const { return m_filters.Total(); } CExpertSignalCustom *ChildSignalAt(const int i) { return (CExpertSignalCustom *)m_filters.At(i); } virtual bool CheckOpenLong(double &price, double &sl, double &tp, datetime &expiration) override; virtual bool CheckOpenShort(double &price, double &sl, double &tp, datetime &expiration) override; virtual bool CheckCloseLong(double &price) override; virtual bool CheckCloseShort(double &price) override; bool OpenParams(bool isLong, double &price, double &sl, double &tp, datetime &expiration); // Added for generalized parameter calculation virtual bool OpenLongParams(double &price, double &sl, double &tp, datetime &expiration) override; virtual bool OpenShortParams(double &price, double &sl, double &tp, datetime &expiration) override; virtual bool ValidationSettings(void) override; virtual bool InitIndicators(CIndicators *indicators) override; void Entry_Multiplier(double entry_multiplier) { m_entry_multiplier = entry_multiplier; } void Periods(int periods) { m_periods = periods; } void SLMode(int value) { m_sl_mode = value; } void TPMode(int value) { m_tp_mode = value; } void HoldToBarrier(bool value) { m_holdToBarrier = value; } bool HoldToBarrier(void) const { return m_holdToBarrier; } //--- HISTORICAL EVALUATION SHIFT. Non-zero only while HistoricalNetVote() replays a past bar for //--- the filtered overlay; 0 = normal live behaviour (base rule: every_tick ? 0 : 1). int m_evalShift; void EvalShift(const int shift) { m_evalShift = shift; } //--- CONFIGURED EVALUATION BAR. Left at -1 by every shipped filter since the classic votes were //--- removed, so StartIndex() falls through to the every_tick base rule; the sweep still wins. int m_shift; void Shift(const int shift) { m_shift = (shift < 0 ? -1 : shift); } virtual int StartIndex(void) { if(m_evalShift > 0) return m_evalShift; return (m_shift >= 0 ? m_shift : (m_every_tick ? 0 : 1)); } //--- Signed live confidence: sign gives direction (+ buy, - sell), magnitude is the calibrated //--- 0..1 conviction. 0.0 = no AI filter / not converged yet. TELEMETRY ONLY - the unsigned //--- AIConfidence() companion was removed 2026-08-25 when its last consumer (the confidence-scaled //--- SL/TP/lot modes) went; see Variables\ConfidenceBridge.mqh for why nothing may read this to //--- size a trade. virtual double SignedAIConfidence(void) { return 0.0; } //--- Returns this instance's own SignedAIConfidence() when it IS an AI signal, otherwise the //--- live value the AI signal publishes each tick (g_LiveAISignedConfidence, see //--- CExpertSignalAIBase::ScheduleTrainingIfNeeded). double LiveSignedConfidence(void); //--- Refreshes the two journalled confidence globals for the trade OpenParams() is about to hand //--- to Money. Returns nothing: it has no callers that act on a value, and keeping it void is what //--- stops it quietly becoming a control input again. void PublishConfidenceTelemetry(void); virtual void ApplyPatternWeight(int patternNumber, int weight) {}; void ID(string id) { m_id = id; } virtual string GetFilterID(void) { return m_id; }; //--- Is this filter one of the neural nets? Overridden true by CExpertSignalAIBase. A virtual //--- rather than a GetFilterID() string comparison because the ids are FOLDER names that outlive //--- display renames (SignalHYBRID's "ConvLSTM"/"HYB" pair), so a name test would silently start //--- returning the wrong answer the next time a model is renamed. virtual bool IsAIFilter(void) const { return false; } //--- CONTROL-PANEL SEAM. The panel used to drive training through g_aiSignals[] in //--- Warrior_EA.mq5 - a hand-maintained, MAX_AI_SIGNALS-capped, AI-only registry that had //--- already dropped a member on the floor once (609be10). virtual bool OnSignalCommand(const ENUM_SIGNAL_COMMAND cmd) { return false; } virtual bool HasSignalTrait(const ENUM_SIGNAL_TRAIT trait) { return false; } //--- Whole-tree walks: this signal plus every filter, recursively. int DispatchSignalCommand(const ENUM_SIGNAL_COMMAND cmd); int CountSignalTrait(const ENUM_SIGNAL_TRAIT trait); //--- Does this filter derive its own pattern weights, making the signal DB's ranking //--- inapplicable to it? virtual bool SelfRanked(void) const { return false; } //--- The weight this filter contributes to the vote's DENOMINATOR - its say in the consensus - //--- independent of whether it votes on this particular bar. virtual double VoteCapableWeight(void) { return (GetPatternCount() > 0) ? m_weight : 0.0; } //--- THE RECONSTRUCTION'S divisor share, which is NOT the live one. //--- //--- VoteCapableWeight() answers "may this member's vote move real money", and for an AI member //--- that is 0 until the whole training run converges. HistoricalNetVote used it as the divisor, //--- so while any model was still training the reconstruction's divisor was zero on EVERY bar, //--- every bar was skipped as "nobody looked", and the filtered view drew nothing at all. Since a //--- run can train for days - and, before the plateau noise band, effectively forever - that is a //--- chart that is blank for its entire useful life. Reported 2026-08-24 as "no signals drawn //--- since the refactor". //--- //--- The overlay is a picture of what the vote WOULD have shown, which is a question a //--- mid-training model can answer; the chart HUD already says so with its "(trn)" marker. Live //--- Direction() keeps VoteCapableWeight() untouched, so no untrained model gains a say in an //--- order because of this. virtual double ReconstructionWeight(void) { return VoteCapableWeight(); } //--- AI filters only: this model's cached decision for bar `idx`, already converted to the signed //--- vote it would have cast. False when the bar was never scored (outside the scan, or a feature //--- window failure), which is NOT the same as an abstention and must not be counted as one. virtual bool CachedVoteAt(const int idx, double &signedVote) { signedVote = 0.0; return false; } //--- Same question asked of the member's ERA-END SNAPSHOT instead of its live cache. The live //--- cache is wiped to sentinel at every era start, so anything reading it is blind for most of //--- every era - the snapshot is copied at pass-3 completion and survives until the next one. virtual bool SnapshotVoteAt(const int idx, double &signedVote) { signedVote = 0.0; return false; } //--- AI filters only: the RESOLVED swing-pivot label at bar `idx` - the truth a reconstructed //--- vote is scored against. False while the pivot pair is uncommitted (no label exists yet). //--- The label is a pure function of the shared chart series, so any one AI member's answer //--- serves the whole vote - see OverlayTruthAt(). virtual bool ReplayTruthAt(const int idx, ENUM_SIGNAL &truth) { truth = Neutral; return false; } //--- What this filter WOULD vote right now if it were allowed to - i.e. its current decision put //--- through the same tier/weight arithmetic, but WITHOUT the readiness gate that stops a model //--- voting before it is deployed. virtual bool ProspectiveVote(double &signedVote, double &weight) { signedVote = 0.0; weight = 0.0; return false; } //--- Snapshot/restore of everything a Direction() call writes that a LATER call reads. That is a //--- corrupted row in the very table the pattern win rates (and now the vote weights) are //--- computed from. void SaveVoteState(string &pl, string &ps, double &nv, int &lw, int &sw, int &fd) { pl = m_active_pattern_long; ps = m_active_pattern_short; nv = m_lastNetVote; lw = m_lastLongWeight; sw = m_lastShortWeight; fd = m_lastFiredDirection; } void RestoreVoteState(const string pl, const string ps, const double nv, const int lw, const int sw, const int fd) { m_active_pattern_long = pl; m_active_pattern_short = ps; m_lastNetVote = nv; m_lastLongWeight = lw; m_lastShortWeight = sw; m_lastFiredDirection = fd; } //--- Chunked historical rebuild of the FILTERED view - see the definition for the whole rationale. //--- The replay half of Direction(), with live's divisor rule. See the definition for why the //--- two cannot simply be one call today. double HistoricalNetVote(const int idx, double &capableOut, bool &hadSnapshotOut); bool AdvanceFilteredOverlay(const int barBudget); void StartFilteredOverlay(void); //--- VOTE-LEVEL COOLDOWN, live side. Idempotent per bar - see m_voteCoolEvalTime for why that is //--- required rather than merely tidy. Returns true (accept) when the cooldown is off. bool VoteCooldownAccept(const datetime barTime) { int window = WarriorSignalCooldownBars(); if(window <= 0 || barTime <= 0) return true; if(m_voteCoolEvalTime == barTime) return m_voteCoolEvalAccept; long minGap = (long)window * PeriodSeconds(); bool accept = !(m_voteCoolKeptTime != 0 && (long)(barTime - m_voteCoolKeptTime) <= minGap); m_voteCoolEvalTime = barTime; m_voteCoolEvalAccept = accept; if(accept) m_voteCoolKeptTime = barTime; return accept; } bool FilteredOverlayPending(void) const { return m_overlayPending; } //--- The sweep's truth source: the first AI filter's resolved label for the bar - see //--- ReplayTruthAt() above. bool OverlayTruthAt(const int idx, ENUM_SIGNAL &truth); //--- One-shot harvest of the completed sweep's combined-vote score (fired calls / correct calls). //--- Consuming read: returns true exactly once per completed sweep, so the caller can never //--- double-count one sweep into the ensemble record. bool TakeOverlayVoteScore(long &fired, long &wins); //--- One-line on-chart readout of the vote that is actually being tested against Signal_ThresholdOpen. void UpdateVoteReadout(const double vote, const int voters, const int neutrals, const bool prospective); //--- Timer-driven repaint of the readout - see the definition for the cadence bug it fixes. void RefreshVoteReadout(void); //--- THIS filter's own arrow namespace. string FilterArrowPrefix(void) { return SIG_ARROW_PREFIX + m_id + "_"; } //--- RAW VIEW: draw this filter's own vote at bar `idx`, named and tooltipped so it identifies //--- itself on a chart carrying several. void DrawRawFilterArrow(const int idx, const string pattern, const bool isBuy, const int weight) { datetime t = iTime(m_symbol.Name(), m_period, idx); //--- THE TRIGGER PRICE: this bar's close, which is where a market order fires and exactly the //--- entry the swing label assumes. The spread the label charges is smaller than a chart pixel //--- at normal zoom, so it is priced but not drawn. double price = iClose(m_symbol.Name(), m_period, idx); WarriorPlotSignalLevel(FilterArrowPrefix() + TimeToString(t), t, (ENUM_TIMEFRAMES)m_period, price, isBuy, false, StringFormat("%s %s %s (weight %d, module %.2f)", m_id, (isBuy ? "Buy" : "Sell"), pattern, weight, m_weight)); } //--- Remove this filter's arrow at bar `idx` - the counterpart to the draw above, for a bar whose //--- vote was withdrawn (a rejected setup, or a redraw that no longer fires there). void EraseRawFilterArrow(const int idx) { datetime t = iTime(m_symbol.Name(), m_period, idx); if(t > 0) WarriorDeleteSignalMark(FilterArrowPrefix() + TimeToString(t)); } //--- FILTERED VIEW: the combined vote, drawn by the AGGREGATE signal and belonging to no filter. //--- Bigger and in its own colours precisely so it does not read as "one more model's opinion" - //--- it is a different kind of statement from the raw arrows and the two must never be confused //--- on a chart that shows either. void DrawVoteArrow(const int idx, const bool isBuy, const double vote, const double sl, const double tp) { datetime t = iTime(m_symbol.Name(), m_period, idx); //--- The trigger price - see DrawRawFilterArrow's note. This is the level the order goes on at. double price = iClose(m_symbol.Name(), m_period, idx); WarriorPlotSignalLevel(SIG_VOTE_PREFIX + TimeToString(t), t, (ENUM_TIMEFRAMES)m_period, price, isBuy, true, StringFormat("TRADE %s @ %s | vote %.1f >= %.1f | SL %s TP %s", (isBuy ? "BUY" : "SELL"), DoubleToString(price, m_symbol.Digits()), vote, m_threshold_open, DoubleToString(sl, m_symbol.Digits()), DoubleToString(tp, m_symbol.Digits()))); } void EraseVoteArrow(const int idx) { datetime t = iTime(m_symbol.Name(), m_period, idx); if(t > 0) WarriorDeleteSignalMark(SIG_VOTE_PREFIX + TimeToString(t)); } //--- Consuming reads (reset to "NULL" on read), one slot per side - the single-label //--- GetActivePattern()/GetActiveDirection() pair they replace let the later-running short ladder //--- steal the long ladder's label (see Direction()'s per-side journaling comment). string GetActivePatternLong(void); string GetActivePatternShort(void); //--- NON-consuming peeks at the same two slots. Same relationship to GetActivePattern*() as //--- m_lastFiredDirection has to those: a pure look, safe to call without stealing the value //--- from the journaling path that must still receive it. string PeekActivePatternLong(void) { return m_active_pattern_long; } string PeekActivePatternShort(void) { return m_active_pattern_short; } double LastNetVote(void) { return m_lastNetVote; } int LastLongWeight(void) { return m_lastLongWeight; } int LastShortWeight(void) { return m_lastShortWeight; } //--- Read access to CExpertSignal's m_weight, which the standard library exposes only as a //--- SETTER. Named ModuleWeight() rather than Weight() so it cannot be mistaken for (or //--- accidentally overload) the library's setter. double ModuleWeight(void) const { return m_weight; } virtual int GetPatternCount(void) { return m_pattern_count; }; //--- Direction()'s two side effects, named so the transaction it performs is visible at the //--- call site instead of buried in the loop that also does the arithmetic. void JournalFilterPatterns(CExpertSignalCustom *filter, const MqlDateTime &brokerTime); void DrawFilterRawView(CExpertSignalCustom *filter); virtual double Direction(void) override; //--- Vote lifecycle hooks, for filters whose LongCondition()/ShortCondition() consume one-shot //--- state when they fire. Base = no-op. virtual void BeginVote(void) {} virtual void RevokeVote(void) {} bool UpdateSignalsWeights(void); //--- priorWeight 0 = raw maximum-likelihood ratio (the pre-2026-08-16 behaviour); >0 shrinks the //--- estimate toward priorPct by that many pseudo-trades. See the definition for why. int WinRateFromCounts(const int wins, const int losses, const double priorPct = -1.0, const int priorWeight = 0); int NormalizeWinRate(double winRate); void ProcessBufferedSignals(void); bool InRange(double value, double min, double max); // Helper function for range checking void UseDatabase(bool value) { m_useDatabase = value; }; void MaxTableRows(int value) { m_maxTableRows = MathMax(1, value); }; //--- event handler virtual void OnTickHandler(void); virtual void OnChartEventHandler(const int id, const long &lparam, const double &dparam, const string &sparam); }; //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CExpertSignalCustom::CExpertSignalCustom(void) : m_id("NULL"), m_active_pattern("NULL"), m_active_direction("NULL"), m_active_pattern_long("NULL"), m_active_pattern_short("NULL"), m_lastNetVote(0.0), m_lastLongWeight(0), m_lastShortWeight(0), m_voteCoolKeptTime(0), m_voteCoolEvalTime(0), m_voteCoolEvalAccept(true), m_overlayVoteCount(0), m_overlayPending(false), m_overlayIndex(0), m_overlayStopIndex(0), m_overlayLiveCutoff(0), m_overlaySweptBars(0), m_overlayHadDataBars(0), m_overlaySnapNoWeightBars(0), m_overlayVotedBars(0), m_overlayDrawn(0), m_overlayBestNet(0.0), m_overlayLastLogDrawn(-1), m_overlayLastLogBest(0.0), m_overlaySkippedLogs(0), m_overlayVotedBuy(0), m_overlayVotedSell(0), m_overlayNmsLastBuyIdx(-1), m_overlayNmsLastSellIdx(-1), m_overlayNmsKeptIdx(-1), m_overlayNmsKeptBuy(false), m_overlayNmsKeptNet(0.0), m_overlayVoteFired(0), m_overlayVoteWins(0), m_overlayScoreReady(false), m_votePeak(0.0), m_lastLiveVoters(0), m_evalShift(0), m_shift(-1), m_maxTableRows(MAX_TABLE_ROWS), m_pattern_count(0), m_entry_multiplier(0), m_prohibition_signal(false), m_periods(14), m_useDatabase(false), m_sl_mode(3), // SL_ATR_x3 m_tp_mode(6), // TP_ATR_x6 m_holdToBarrier(false), m_dbConfidence(0.0), m_directionCurrentSecond(0), m_directionAggregatedResult(0.0), m_directionCount(0), m_directionLastResult(0.0), m_lastFiredDirection(0) { } //+------------------------------------------------------------------+ //| Live signed confidence: own reading, else the ensemble's mean | //+------------------------------------------------------------------+ double CExpertSignalCustom::LiveSignedConfidence(void) { double own = SignedAIConfidence(); if(own != 0.0) return own; //--- THE ORCHESTRATOR COMBINES; the members only publish. g_LiveAISignedConfidence = AggregateAIVotes(); return g_LiveAISignedConfidence; } //+------------------------------------------------------------------+ //| Snapshot both confidence readings for the journal - see the | //| telemetry rule at the top of Variables\ConfidenceBridge.mqh. | //+------------------------------------------------------------------+ void CExpertSignalCustom::PublishConfidenceTelemetry(void) { g_AISignedConfidence = LiveSignedConfidence(); g_DBConfidence = m_dbConfidence; } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CExpertSignalCustom::~CExpertSignalCustom(void) { ArrayFree(signalBuffer); } //+------------------------------------------------------------------+ //| Tester-only trade rejection tracing | //+------------------------------------------------------------------+ bool CExpertSignalCustom::ShouldTraceTradeRejections(void) const { return VerboseMode; } void TraceSignalRejection(const string key, const string message) { if(!VerboseMode) return; TCLog("signal-reject:" + key, message); } //+------------------------------------------------------------------+ //| Single source of truth for the per-pattern/direction table name | //+------------------------------------------------------------------+ string CExpertSignalCustom::PatternTableName(string filterID, string pattern, string direction) { return filterID + "_" + pattern + "_" + direction; } //+------------------------------------------------------------------+ //| Helper function to check value ranges | //+------------------------------------------------------------------+ bool CExpertSignalCustom::InRange(double value, double min, double max) { return value >= min && value <= max; } //+------------------------------------------------------------------+ //| Validation settings protected data | //+------------------------------------------------------------------+ bool CExpertSignalCustom::ValidationSettings(void) { if(!CExpertSignal::ValidationSettings()) return false; // Simplified checks using the InRange helper if(!InRange(m_periods, 0, 200)) { printf(__FUNCTION__ ": ATR Periods must be 0-200"); return false; } if(!InRange(StartIndex(), 0, 200)) { printf(__FUNCTION__ ": ATR shift must be 0-200"); return false; } return true; } //+------------------------------------------------------------------+ //| Create indicators | //+------------------------------------------------------------------+ bool CExpertSignalCustom::InitIndicators(CIndicators *indicators) { //--- check pointer if(indicators == NULL) return(false); //--- CExpertSignal *filter; //--- WHOLE TREE: a gate is still a signal and needs its indicators and series. It is missing from //--- CExpertSignal::InitIndicators below (that walks m_filters), which is why it is done here. int total = ChildSignalCount(); //--- gather information about using of timeseries for(int i = 0; i < total; i++) { filter = ChildSignalAt(i); if(filter == NULL) continue; m_used_series |= filter.UsedSeries(); } //--- create required timeseries if(!CExpertBase::InitIndicators(indicators)) return(false); //--- initialization of indicators and timeseries in the additional filters for(int i = 0; i < total; i++) { filter = ChildSignalAt(i); if(filter == NULL) continue; filter.SetPriceSeries(m_open, m_high, m_low, m_close); filter.SetOtherSeries(m_spread, m_time, m_tick_volume, m_real_volume); if(!filter.InitIndicators(indicators)) return(false); } if(!indicators.Add(GetPointer(m_ATR)) || !m_ATR.Create(m_symbol.Name(), m_period, m_periods) || !CExpertSignal::InitIndicators(indicators)) { printf(__FUNCTION__ ": error initializing indicators"); return false; } return true; } //+------------------------------------------------------------------+ //| Setting an additional filter | //+------------------------------------------------------------------+ bool CExpertSignalCustom::AddFilter(CExpertSignal *filter) { if(filter == NULL) return false; if(!filter.Init(m_symbol, m_period, m_adjusted_point)) return false; if(!m_filters.Add(filter)) return false; filter.EveryTick(m_every_tick); filter.Magic(m_magic); CExpertSignalCustom *customFilter = dynamic_cast(filter); if(customFilter != NULL) { string filterID = customFilter.GetFilterID(); if(filterID != "NULL" && m_useDatabase) { int patternCount = customFilter.GetPatternCount(); for(int i = 0; i < patternCount; i++) { string tableNameBuy = PatternTableName(filterID, PatternName(i), "Buy"); string tableNameSell = PatternTableName(filterID, PatternName(i), "Sell"); dbm.CreateTable(tableNameBuy, tableschema); // Create table for Buy direction dbm.CreateTable(tableNameSell, tableschema); // Create table for Sell direction } } } return true; } //+------------------------------------------------------------------+ //| Which order type a given entry price will actually produce. | //| CExpertTrade::Buy()/Sell() route on price vs ask/bid +- the | //| SYMBOL_TRADE_STOPS_LEVEL: further out than that in the pending | //| direction becomes a stop/limit order, anything nearer becomes a | //| market fill. | //+------------------------------------------------------------------+ ENUM_ORDER_TYPE CExpertSignalCustom::ResolveOrderType(bool isLong, double price) { return TCResolveOrderType(m_symbol.Name(), isLong, price, m_symbol.Ask(), m_symbol.Bid()); } //+------------------------------------------------------------------+ //| Wrapper functions for buying and selling parameters | //+------------------------------------------------------------------+ bool CExpertSignalCustom::OpenParams(bool isLong, double &price, double &sl, double &tp, datetime &expiration) { int idx = StartIndex(); double atr = m_ATR.Main(idx); if(!MathIsValidNumber(atr) || atr <= 0.0) return false; // ATR must be positive if(!m_symbol.Name(_Symbol)) return false; // Symbol information must be accessible //--- Article 2555 #14: every symbol-property read below (stops level, point, digits) silently //--- returns 0 for a symbol that is not selected/quoted, which would turn each of the checks //--- further down into an unconditional pass. Verify the symbol is real and quoted first. string tc_reason; if(!TCSymbolIsTradeable(m_symbol.Name(), tc_reason)) { TraceSignalRejection("openparams-symbol:" + m_symbol.Name(), __FUNCTION__ + ": rejected - " + tc_reason); return false; } int lookback_period = m_periods; //--- Article 2555 #8: iLowest/iHighest below scan `lookback_period` bars starting at `idx`, and //--- the ATR read above needs its own warm-up. Rather than discovering the shortfall as a -1 //--- index (handled below) or as a silently truncated scan, check the series depth up front and //--- let the terminal build the missing history - the next tick finds it ready. if(!TCHasEnoughHistory(m_symbol.Name(), m_period, lookback_period + idx + m_periods, tc_reason)) { TraceSignalRejection("openparams-history:" + m_symbol.Name(), __FUNCTION__ + ": rejected - " + tc_reason); return false; } double base_price = (m_base_price == 0.0) ? (isLong ? m_symbol.Ask() : m_symbol.Bid()) : m_base_price; if(!MathIsValidNumber(base_price) || base_price <= 0.0) return false; // Price feed must be valid // Keep swing sourcing strictly bound to this signal's symbol/timeframe. Mixing chart globals // here can yield index/value mismatches in tester runs and diverge from classic behavior. int lowest_index = iLowest(m_symbol.Name(), m_period, MODE_LOW, lookback_period, idx); int highest_index = iHighest(m_symbol.Name(), m_period, MODE_HIGH, lookback_period, idx); //--- Whether the swing prices are actually USED by this configuration. Since 2026-07-31 only //--- ENTRY_PREV_SWING consumes them - SL and TP are both entry-anchored ATR multiples now. Kept //--- as guards rather than deleted because a bad swing must still never reach an entry price. bool needSwings = ((int)m_entry_multiplier == ENTRY_PREV_SWING_MODE); if(needSwings && (lowest_index < 0 || highest_index < 0)) { // iLowest/iHighest return -1 when the requested history isn't synced yet (thin symbol history, // timeframe just changed, broker feed gap). Indexing Low()/High() with -1 would otherwise feed // a bogus swing price into SL/TP below - reject the setup instead. if(ShouldTraceTradeRejections()) TraceSignalRejection("openparams-swing-index:" + m_symbol.Name(), __FUNCTION__ + ": rejected - iLowest/iHighest returned an invalid index (lowest=" + IntegerToString(lowest_index) + ", highest=" + IntegerToString(highest_index) + ") for " + m_symbol.Name() + ", insufficient history synced."); return false; } //--- Index can legitimately be -1 here when !needSwings (the guard above no longer rejects for //--- it), and iLow/iHigh with a negative index is undefined - so never call it in that case. double lowest_low = (lowest_index >= 0) ? iLow(m_symbol.Name(), m_period, lowest_index) : 0.0; double highest_high = (highest_index >= 0) ? iHigh(m_symbol.Name(), m_period, highest_index) : 0.0; if(needSwings && (lowest_low >= DBL_MAX * 0.5 || highest_high >= DBL_MAX * 0.5)) { if(ShouldTraceTradeRejections()) TraceSignalRejection("openparams-swing-sentinel:" + m_symbol.Name(), StringFormat("%s: rejected - swing prices are sentinel-like (lowest_low=%g, highest_high=%g, symbol=%s, period=%d, low_idx=%d, high_idx=%d).", __FUNCTION__, lowest_low, highest_high, m_symbol.Name(), m_period, lowest_index, highest_index)); return false; } if(needSwings && (!MathIsValidNumber(lowest_low) || !MathIsValidNumber(highest_high))) { if(ShouldTraceTradeRejections()) TraceSignalRejection("openparams-swing-nonfinite:" + m_symbol.Name(), StringFormat("%s: rejected - swing prices are not finite (lowest_low=%g, highest_high=%g, symbol=%s, period=%d).", __FUNCTION__, lowest_low, highest_high, m_symbol.Name(), m_period)); return false; } if(needSwings && (lowest_low <= 0.0 || highest_high <= 0.0)) { if(ShouldTraceTradeRejections()) TraceSignalRejection("openparams-swing-nonpositive:" + m_symbol.Name(), StringFormat("%s: rejected - swing prices are non-positive (lowest_low=%g, highest_high=%g, symbol=%s, period=%d).", __FUNCTION__, lowest_low, highest_high, m_symbol.Name(), m_period)); return false; } //--- Snapshot the model's conviction for the trade journal. NOTHING BELOW THIS LINE READS IT - //--- the confidence-scaled entry/SL/TP modes were removed 2026-08-25 (see ConfidenceBridge.mqh). //--- It is refreshed here, at the last moment before Money is consulted, purely so the journal row //--- records what the model actually believed at the instant the trade was priced. PublishConfidenceTelemetry(); //--- --- Entry price. Offsets are measured from the CURRENT price (base_price = bid/ask), except //--- ENTRY_PREV_SWING which anchors to the recent swing. int entryMode = (int)m_entry_multiplier; if(entryMode == ENTRY_PREV_SWING_MODE) price = m_symbol.NormalizePrice(isLong ? lowest_low : highest_high); else // Fixed ATR presets: buy => base + mult*ATR (limit below / stop above for -/+ mult); // sell => base - mult*ATR (limit above / stop below). MARKET (0) leaves price at bid/ask. price = m_symbol.NormalizePrice(isLong ? (base_price + entryMode * atr) : (base_price - entryMode * atr)); //--- --- Stop loss: always ENTRY-anchored, a straight ATR multiple below (long) / above (short) //--- the entry price. Every SL_ATR_* preset is that multiple, used verbatim - the mode is a number, //--- not a strategy, which is the whole point of handing it to the GA. double slMultiplier = (double)m_sl_mode; sl = isLong ? m_symbol.NormalizePrice(price - slMultiplier * atr) : m_symbol.NormalizePrice(price + slMultiplier * atr); //--- Enforce a hard minimum SL distance from entry (broker stop-level / sanity floor). if(fabs(price - sl) < (MIN_SL_ATR_MULTIPLIER * atr)) sl = isLong ? (price - MIN_SL_ATR_MULTIPLIER * atr) : (price + MIN_SL_ATR_MULTIPLIER * atr); //--- --- Take profit: TP_ATR_* are an ATR multiple FROM THE ENTRY PRICE, used verbatim for the same //--- reason as the stop above. double tpMultiplier = (double)m_tp_mode; tp = isLong ? m_symbol.NormalizePrice(price + tpMultiplier * atr) : m_symbol.NormalizePrice(price - tpMultiplier * atr); // Guard rail: when both AI and classic share this path, any non-finite or negative level here is an // upstream data/state issue, not a mode-specific feature. Reject early with full context. if(!MathIsValidNumber(price) || price < 0.0 || !MathIsValidNumber(sl) || sl < 0.0 || !MathIsValidNumber(tp) || tp < 0.0) { if(ShouldTraceTradeRejections()) TraceSignalRejection("openparams-invalid-levels:" + m_symbol.Name(), StringFormat("%s: rejected - invalid computed levels (isLong=%s, entryMode=%d, slMode=%d, tpMode=%d, atr=%g, base=%g, low=%g, high=%g, price=%g, sl=%g, tp=%g).", __FUNCTION__, isLong ? "true" : "false", entryMode, m_sl_mode, m_tp_mode, atr, base_price, lowest_low, highest_high, price, sl, tp)); return false; } //--- --- Article 2555 #6: SL and TP must clear SYMBOL_TRADE_STOPS_LEVEL, measured against the price //--- of the OPPOSITE operation for a market order (a long closes at Bid, a short at Ask) or against //--- the activation price for a pending one. ENUM_ORDER_TYPE order_type = ResolveOrderType(isLong, price); string stops_note; if(!TCAdjustStops(m_symbol.Name(), order_type, price, sl, tp, stops_note)) { TraceSignalRejection("openparams-stops:" + m_symbol.Name(), __FUNCTION__ + ": rejected - " + stops_note); return false; } if(stops_note != "") TraceSignalRejection("openparams-stops-adj:" + m_symbol.Name(), __FUNCTION__ + ": " + stops_note); // (TCAdjustStops() can widen the stop, which changes this trade's real risk. Nothing downstream // reads that risk any more - the reward:risk recompute that stood here went with its last // consumer - but the widening itself still matters and is re-verified immediately below.) // Re-verify rather than trust the correction: TCAdjustStops() widens levels, and a caller that // hands it a nonsensical pair (SL on the wrong side of the entry) can still come back illegal. if(!TCCheckStops(m_symbol.Name(), order_type, price, sl, tp, stops_note)) { TraceSignalRejection("openparams-stops-final:" + m_symbol.Name(), __FUNCTION__ + ": rejected - " + stops_note); return false; } // A pending order's own activation price is subject to the same minimum distance. If `price` // drifted inside it between the entry calculation above and now, CExpertTrade would quietly // downgrade the order to a market fill at a price the setup never asked for - reject instead. if(order_type != ORDER_TYPE_BUY && order_type != ORDER_TYPE_SELL && !TCCheckPendingPrice(m_symbol.Name(), order_type, price, stops_note)) { TraceSignalRejection("openparams-pending:" + m_symbol.Name(), __FUNCTION__ + ": rejected - " + stops_note); return false; } // Article 2555 #4: a pending order also has to fit inside ACCOUNT_LIMIT_ORDERS. Checked here, // before the setup is handed to Money for sizing, so a full order book costs nothing downstream. if(order_type != ORDER_TYPE_BUY && order_type != ORDER_TYPE_SELL && !TCIsNewOrderAllowed(stops_note)) { TraceSignalRejection("openparams-orderlimit", __FUNCTION__ + ": rejected - " + stops_note); return false; } //--- REWARD:RISK IS NEITHER ENFORCED NOR CONSUMED ANY MORE. The minimum-ratio rejection went with the //--- Min_Risk_Reward_Ratio input (2026-08-09); the g_TradeRewardRiskRatio bridge that survived it fed //--- exactly one reader, CMoneyIntelligent's Kelly sizing, and went with that class 2026-08-25. With //--- SL and TP both literal ATR multiples the ratio is now a fixed property of the two inputs //--- (TP_Mode / SL_Mode) rather than a per-trade discovery, so there is nothing left to publish: the //--- GA already sees it directly in the pair of values it is sweeping. // Adjust expiration time expiration += m_expiration * PeriodSeconds(m_period); return true; } //+------------------------------------------------------------------+ //| Detecting the levels for buying | //+------------------------------------------------------------------+ bool CExpertSignalCustom::OpenLongParams(double &price, double &sl, double &tp, datetime &expiration) { return OpenParams(true, price, sl, tp, expiration); } //+------------------------------------------------------------------+ //| Detecting the levels for selling | //+------------------------------------------------------------------+ bool CExpertSignalCustom::OpenShortParams(double &price, double &sl, double &tp, datetime &expiration) { return OpenParams(false, price, sl, tp, expiration); } //+------------------------------------------------------------------+ //| Common function for closing positions | //+------------------------------------------------------------------+ bool CExpertSignalCustom::CheckClosePosition(bool isLong, double &price) { //--- Hold-to-barrier: no vote-driven exit of any kind - see m_holdToBarrier's declaration comment. //--- The base price is still zeroed, exactly as the normal path below does on every call. if(m_holdToBarrier) { m_base_price = 0.0; return false; } bool result = false; //--- check of exceeding the threshold value, adjusted for long/short double directionMultiplier = isLong ? -1 : 1; // Allowing position closing without checking the prohibition signal. if(directionMultiplier * m_direction >= m_threshold_close) result = true; if(result) { //--- try to get the level of closing, differentiating based on isLong if(!(isLong ? CloseLongParams(price) : CloseShortParams(price))) result = false; } //--- zeroize the base price m_base_price = 0.0; //--- return the result return result; } //+------------------------------------------------------------------+ //| Generating a signal for closing of a long position | //+------------------------------------------------------------------+ bool CExpertSignalCustom::CheckCloseLong(double &price) { return CheckClosePosition(true, price); } //+------------------------------------------------------------------+ //| Generating a signal for closing a short position | //+------------------------------------------------------------------+ bool CExpertSignalCustom::CheckCloseShort(double &price) { return CheckClosePosition(false, price); } //+------------------------------------------------------------------+ //| Common function for opening positions | //+------------------------------------------------------------------+ bool CExpertSignalCustom::CheckOpenPosition(bool isLong, double &price, double &sl, double &tp, datetime &expiration) { bool result = false; //--- the "prohibition" signal if(m_prohibition_signal == true) { if(ShouldTraceTradeRejections()) TraceSignalRejection("open-prohibition", StringFormat("%s: open %s rejected - a child filter vetoed the tick (prohibition signal).", __FUNCTION__, isLong ? "long" : "short")); return false; } //--- MARKET-HOURS GATE (2026-08-19). Entries only - exits, SL/TP and the scheduled close-all stay //--- unguarded on purpose: closing risk must never be blocked by a session boundary. if(!WarriorMarketOpenNow(m_symbol.Name(), TimeCurrent())) { if(ShouldTraceTradeRejections()) TraceSignalRejection("open-market-closed", StringFormat("%s: open %s rejected - outside the symbol's trading sessions.", __FUNCTION__, isLong ? "long" : "short")); return false; } //--- check of exceeding the threshold value, adjusted for long/short double directionMultiplier = isLong ? 1 : -1; if(directionMultiplier * m_direction >= m_threshold_open) { //--- VOTE-LEVEL COOLDOWN. Gated HERE, where the vote becomes a trade and the live arrow is //--- drawn, so suppression means no order AND no arrow - the same "no arrow, no vote, no //--- position" contract the member-level rule already honours. if(!VoteCooldownAccept(iTime(m_symbol.Name(), m_period, 0))) { if(ShouldTraceTradeRejections()) TraceSignalRejection("open-cooldown", StringFormat("%s: open %s rejected - inside the %d-bar signal" " cooldown after the last kept vote.", __FUNCTION__, isLong ? "long" : "short", WarriorSignalCooldownBars())); return false; } //--- there's a signal result = true; //--- try to get the levels of opening, differentiating based on isLong if(!(isLong ? OpenLongParams(price, sl, tp, expiration) : OpenShortParams(price, sl, tp, expiration))) { //--- FILTERED VIEW, and the reason this arrow is drawn HERE and not where the threshold is //--- cleared: passing the vote is not the same as trading. A setup can clear //--- Signal_ThresholdOpen and still never reach the broker - invalid SL/TP, stops-level, ATR //--- warm-up, unsynced swing history - and every one of those failures lands in this branch. EraseVoteArrow(StartIndex()); // The vote reached the threshold but entry-shaping failed (invalid SL/TP, broker constraints, // missing history). Roll back one-shot child vote state so the same directional signal can // be re-offered on the next bar instead of being permanently consumed by this failed attempt. int total = m_filters.Total(); for(int i = 0; i < total; i++) { CExpertSignalCustom *filter = m_filters.At(i); if(filter != NULL) filter.RevokeVote(); } RevokeVote(); if(ShouldTraceTradeRejections()) TraceSignalRejection("open-params-failed", StringFormat("%s: open %s rejected after direction passed threshold - order parameters failed validation (vote state restored for retry).", __FUNCTION__, isLong ? "long" : "short")); result = false; } //--- SURVIVED EVERYTHING: the vote cleared the threshold, no filter vetoed the tick, and the //--- order parameters validated. THIS is the bar the EA would have placed an order on, so this //--- is the only place the filtered view may mark. One arrow == one entry the bot would take. else if(!DrawUnfilteredSignals) DrawVoteArrow(StartIndex(), isLong, directionMultiplier * m_direction, sl, tp); } else if(ShouldTraceTradeRejections()) { TraceSignalRejection("open-threshold", StringFormat("%s: open %s rejected - direction %.2f did not reach threshold %.2f.", __FUNCTION__, isLong ? "long" : "short", directionMultiplier * m_direction, m_threshold_open)); } //--- zeroize the base price m_base_price = 0.0; //--- return the result return result; } //+------------------------------------------------------------------+ //| Generating a buy signal | //+------------------------------------------------------------------+ bool CExpertSignalCustom::CheckOpenLong(double &price, double &sl, double &tp, datetime &expiration) { // Check if the trading strategy allows opening long positions if(WarriorDirectionAllows(true)) { return CheckOpenPosition(true, price, sl, tp, expiration); } // The effective policy blocks longs if(ShouldTraceTradeRejections()) TraceSignalRejection("open-long-direction-block", StringFormat("%s: open long rejected - strategy direction blocks long entries.", __FUNCTION__)); return false; } //+------------------------------------------------------------------+ //| Generating a sell signal | //+------------------------------------------------------------------+ bool CExpertSignalCustom::CheckOpenShort(double &price, double &sl, double &tp, datetime &expiration) { // Check if the trading strategy allows opening short positions if(WarriorDirectionAllows(false)) { return CheckOpenPosition(false, price, sl, tp, expiration); } // The effective policy blocks shorts if(ShouldTraceTradeRejections()) TraceSignalRejection("open-short-direction-block", StringFormat("%s: open short rejected - strategy direction blocks short entries.", __FUNCTION__)); return false; } //+------------------------------------------------------------------+ //| Return the long ladder's matched pattern (consuming read) | //+------------------------------------------------------------------+ string CExpertSignalCustom::GetActivePatternLong(void) { string ret = m_active_pattern_long; m_active_pattern_long = "NULL"; return ret; } //+------------------------------------------------------------------+ //| Return the short ladder's matched pattern (consuming read) | //+------------------------------------------------------------------+ string CExpertSignalCustom::GetActivePatternShort(void) { string ret = m_active_pattern_short; m_active_pattern_short = "NULL"; return ret; } //+------------------------------------------------------------------+ //| SIDE EFFECT 1 of Direction(): journal this filter's matched | //| patterns to the signal DB. | //| | //| Per side, keyed on the ladder having SET a label rather than on | //| its returned weight - a pattern ranked down to weight 0 by | //| UpdateSignalsWeights() still FIRED, and gating the row on weight | //| would freeze a 0%-win-rate pattern out of the very table that | //| could ever raise it back. The net vote is stored as data | //| (netVote column), not used as a drop filter. | //+------------------------------------------------------------------+ void CExpertSignalCustom::JournalFilterPatterns(CExpertSignalCustom *filter, const MqlDateTime &brokerTime) { if(filter == NULL || !m_useDatabase) return; string filterID = filter.GetFilterID(); if(filterID == "NULL") return; double filterNetVote = filter.LastNetVote(); string patternLong = filter.GetActivePatternLong(); string patternShort = filter.GetActivePatternShort(); if(patternLong != "NULL") BufferNewTickSignal(filterID, patternLong, "Buy", brokerTime, m_symbol.Ask(), filterNetVote); if(patternShort != "NULL") BufferNewTickSignal(filterID, patternShort, "Sell", brokerTime, m_symbol.Bid(), filterNetVote); } //+------------------------------------------------------------------+ //| SIDE EFFECT 2 of Direction(): the RAW per-model view. | //| | //| Classic filters only - an AI member's raw arrows come from its | //| own cache, not from a live tick. Erasing when neither ladder | //| matched is deliberate: a stale arrow on a bar that no longer | //| matches is a lie about what the model sees now. | //+------------------------------------------------------------------+ void CExpertSignalCustom::DrawFilterRawView(CExpertSignalCustom *filter) { if(filter == NULL || !DrawUnfilteredSignals || filter.IsAIFilter()) return; int rawIdx = filter.StartIndex(); string freshLong = filter.PeekActivePatternLong(); string freshShort = filter.PeekActivePatternShort(); if(freshLong != "NULL") filter.DrawRawFilterArrow(rawIdx, freshLong, true, filter.LastLongWeight()); else if(freshShort != "NULL") filter.DrawRawFilterArrow(rawIdx, freshShort, false, filter.LastShortWeight()); else filter.EraseRawFilterArrow(rawIdx); } //+------------------------------------------------------------------+ //| Detecting the "weighted" direction | //+------------------------------------------------------------------+ double CExpertSignalCustom::Direction(void) { //--- BROKER TIME (2026-08-19, dbVersion 4.0): this one clock stamps every journaled DB row (the //--- SignalInfo build below) and keys the once-per-second vote window. One clock, the broker's, //--- everywhere. MqlDateTime brokerTime; datetime nowBroker = TimeCurrent(brokerTime); // full timestamp AND broken-down form - both are used below //--- Open a fresh intra-second averaging window whenever the second changes. This block may ONLY //--- reset the window - it must never be the thing that publishes m_directionLastResult. if(nowBroker != m_directionCurrentSecond) { m_directionAggregatedResult = 0.0; m_directionCount = 0; m_directionCurrentSecond = nowBroker; // Update the current second } m_prohibition_signal = false; BeginVote(); // snapshot any one-shot vote state, so a discarded vote can be rolled back - see BeginVote() //--- Evaluate the two ladders separately and snapshot each one's matched pattern into its own side //--- slot, keyed on the ladder having SET a label rather than on its returned weight - a pattern //--- ranked down to weight 0 by UpdateSignalsWeights() still fired, and gating the snapshot on //--- weight would freeze a 0%-win-rate pattern out of the very table that could ever raise it back. //--- The scratch is cleared before each call so a stale label from a previous bar (or the other //--- ladder) can never be attributed to a ladder that matched nothing this bar. m_active_pattern = "NULL"; int longResult = LongCondition(); m_active_pattern_long = m_active_pattern; m_active_pattern = "NULL"; int shortResult = ShortCondition(); m_active_pattern_short = m_active_pattern; m_lastNetVote = longResult - shortResult; m_lastLongWeight = longResult; m_lastShortWeight = shortResult; double result = m_weight * (longResult - shortResult); //--- Non-consuming quorum peek - see m_lastFiredDirection's declaration comment. Snapshotted from //--- this filter's OWN vote, before the loop below adds any children's contributions in. m_lastFiredDirection = (result > 0.0) ? 1 : ((result < 0.0) ? -1 : 0); //--- Seeded with this signal's OWN vote, on the same condition it always was: a filter with //--- children of its own is a member of its own consensus. Same accumulator the replay uses - //--- see SVoteAccumulator for why there is exactly one of these. SVoteAccumulator vote; vote.Add(result, (result == 0.0) ? 0.0 : m_weight); int total = m_filters.Total(); PrintVerbose("Starting direction calculation with total filters: " + IntegerToString(total)); //--- Pass 1: refresh every filter's own Direction() - required regardless of quorum, since this //--- is what drives each filter's own training/DB-buffering/m_lastFiredDirection side effects - //--- caching the returned magnitude for pass 2 below instead of summing it immediately. double directions[]; ArrayResize(directions, total); bool aborted = false; for(int i = 0; i < total; i++) { long mask = ((long)1) << i; if((m_ignore & mask) != 0) { directions[i] = EMPTY_VALUE; continue; } CExpertSignalCustom *filter = m_filters.At(i); if(filter == NULL) { Print("Error: Filter at index " + IntegerToString(i) + " is NULL"); directions[i] = EMPTY_VALUE; continue; } JournalFilterPatterns(filter, brokerTime); double direction = filter.Direction(); //--- AFTER the Direction() call, not beside the journaling: the raw view draws what this //--- evaluation just matched, and before the call that is still last bar's match. DrawFilterRawView(filter); if(direction == EMPTY_VALUE) { m_prohibition_signal = true; directions[i] = EMPTY_VALUE; continue; } // Validate the result to be within the range of -100 to 100 if(direction < -100 || direction > 100) { PrintVerbose("A filter's direction is invalid. Skipping tick."); result = 0; aborted = true; break; } directions[i] = direction; } //--- The tick was discarded, so NO filter's vote was used - roll every one of them back, for the same //--- reason a quorum-suppressed vote is rolled back in pass 2 below (see BeginVote()/RevokeVote()). if(aborted) { for(int i = 0; i < total; i++) { CExpertSignalCustom *filter = m_filters.At(i); if(filter != NULL) filter.RevokeVote(); } } //--- Pass 2: sum each filter's cached contribution, and accumulate the CONSENSUS denominator. if(!aborted) { for(int i = 0; i < total; i++) { double direction = directions[i]; if(direction == EMPTY_VALUE) continue; CExpertSignalCustom *filter = m_filters.At(i); //--- The say this filter has, granted by CAPABILITY rather than by participation - see //--- VoteCapableWeight(). Accumulated before the abstention skip on purpose: an abstainer //--- dilutes, that is the whole point of consensus. long mask = ((long)1) << i; double signedDir = ((m_invert & mask) != 0) ? -direction : direction; vote.Add(signedDir, filter.VoteCapableWeight()); } } //--- An aborted tick published nothing, so it has no voters and no net - see the rollback above. int number = aborted ? 0 : vote.voters; if(!aborted) result = vote.Net(); //--- Normalized by SVoteAccumulator above: the divisor is the CAPABLE weight, so the result reads //--- as "win-rate estimate x fraction of the ensemble's trust that agrees, net". //--- Fold this call's result into the current second's window and publish the window average - see //--- the window-reset block at the top of this function for why this must happen here. m_directionAggregatedResult += result; m_directionCount++; m_directionLastResult = m_directionAggregatedResult / m_directionCount; // Validate the aggregated result to be within the range of -100 to 100 if(m_directionLastResult < -100 || m_directionLastResult > 100) { m_directionLastResult = 0.0; // Set result to 0 if it's outside the range Print("Directional result is out of range. Setting to 0."); } //--- READOUT, aggregate only. Placed AFTER the range check so the label shows what the threshold //--- is actually tested against, not a pre-clamp value. if(total > 0) { //--- NOBODY VOTED - and by far the most common reason is that no model is DEPLOYED yet, not //--- that they all abstained. m_lastLiveVoters = number; //--- neutrals = -1: the live pass does not track how many filters answered Neutral (they are //--- skipped in pass 2 without a count), so the label shows the plain voter count here. if(number > 0) UpdateVoteReadout(m_directionLastResult, number, -1, false); else RefreshVoteReadout(); } PrintVerbose("Final directional result: " + DoubleToString(m_directionLastResult)); return m_directionLastResult; } //+------------------------------------------------------------------+ //| handles the new bar signal buffering | //+------------------------------------------------------------------+ void CExpertSignalCustom::BufferNewTickSignal(string filterID, string pattern, string bias, const MqlDateTime& brokerTime, double entryPrice, double netVote) { if(filterID == "NULL" || pattern == "NULL" || bias == "NULL") { Print("Error buffering new tick signal: Invalid filter parameters - filterID: '" + filterID + "', pattern: '" + pattern + "', bias: '" + bias + "'."); return; } string tableName = PatternTableName(filterID, pattern, bias); SignalInfo signal = {brokerTime.year, brokerTime.mon, brokerTime.day, brokerTime.day_of_week, brokerTime.hour, brokerTime.min, tableName, pattern, bias, entryPrice, netVote}; BufferSignal(signal); PrintVerbose("New tick signal buffered: " + tableName + ", Pattern: " + pattern + ", Bias: " + bias + ", Entry Price: " + DoubleToString(entryPrice)); } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ void CExpertSignalCustom::BufferSignal(SignalInfo &signal) { // Check for duplicate signals in the buffer for(int i = 0; i < ArraySize(signalBuffer); i++) { if(signalBuffer[i].tableName == signal.tableName && signalBuffer[i].pattern == signal.pattern && signalBuffer[i].direction == signal.direction) { PrintVerbose("Duplicate signal detected, not adding to buffer: " + signal.tableName + ", Pattern: " + signal.pattern + ", Direction: " + signal.direction); return; // Skip buffering if a duplicate is found } } // Resize the buffer and add the new signal ArrayResize(signalBuffer, ArraySize(signalBuffer) + 1); signalBuffer[ArraySize(signalBuffer) - 1] = signal; PrintVerbose("Signal buffered for: " + signal.tableName + ", Pattern: " + signal.pattern + ", Direction: " + signal.direction); } //+------------------------------------------------------------------+ //| Process the signal and update trades | //+------------------------------------------------------------------+ void CExpertSignalCustom::ProcessSignal(SignalInfo &signal) { string currentTableName = signal.tableName; string oppositeTableName = currentTableName; // Start with a copy of the current table name PrintVerbose("Processing signal for table: " + currentTableName); // Swap the direction in the table name to get the opposite table name if(signal.direction == "Buy") { StringReplace(oppositeTableName, "Buy", "Sell"); PrintVerbose("Swapped to opposite table: " + oppositeTableName + " from Buy to Sell"); } else { StringReplace(oppositeTableName, "Sell", "Buy"); PrintVerbose("Swapped to opposite table: " + oppositeTableName + " from Sell to Buy"); } //--- Every question below is answered by a targeted SQL lookup returning one row or one number. //--- Per-signal cost is now flat in table size. int curCount = 0, oppCount = 0; if(!dbm.FetchRecordCount(currentTableName, curCount)) { Print("Failed to count current direction trades in: " + currentTableName); return; } if(!dbm.FetchRecordCount(oppositeTableName, oppCount)) { Print("Failed to count opposite direction trades in: " + oppositeTableName); return; } if(curCount >= m_maxTableRows) DeleteOldestEntry(currentTableName); if(oppCount >= m_maxTableRows) DeleteOldestEntry(oppositeTableName); //--- Close the opposite direction's open trade, if any. Closing does NOT absorb the signal: the //--- reversing signal still registers its own trade below (true stop-AND-reverse). State patterns //--- escaped only by re-firing one bar later. The side that never registered also never got a win //--- rate, so UpdateSignalsWeights() weighted the pattern from one side only. string oppositeDirection = (signal.direction == "Buy") ? "Sell" : "Buy"; double oppEntry = 0.0; bool oppOpen = false; if(!dbm.FetchOpenTradeEntry(oppositeTableName, signal.pattern, oppositeDirection, oppEntry, oppOpen)) return; if(oppOpen) { double profitLoss = (oppositeDirection == "Buy") ? (signal.entryPrice - oppEntry) : (oppEntry - signal.entryPrice); TradeRecord closeRec; closeRec.pattern = signal.pattern; closeRec.direction = oppositeDirection; closeRec.exitPrice = signal.entryPrice; closeRec.result = profitLoss >= 0 ? "Profit" : "Loss"; UpdateTradeRecordInDatabase(oppositeTableName, closeRec); PrintVerbose("Closed opposite trade: " + oppositeTableName + ", Profit/Loss: " + DoubleToString(profitLoss)); } //--- Duplicate / outdated / out-of-order guard: rows are inserted in chronological order, so the //--- newest row (max ROWID) carries the table's latest timestamp; a signal at or before it is a //--- duplicate or a replay and must not register. long newestKey = 0; bool hasRows = false; if(!dbm.FetchNewestTimeKey(currentTableName, newestKey, hasRows)) return; long sigKey = SignalTimeKey(signal.year, signal.month, signal.day, signal.hour, signal.minutes); if(hasRows && newestKey >= sigKey) { PrintVerbose("Duplicate or outdated signal, not registering. Table: " + currentTableName); return; } // One open trade per pattern+side at most double curEntry = 0.0; bool curOpen = false; if(!dbm.FetchOpenTradeEntry(currentTableName, signal.pattern, signal.direction, curEntry, curOpen)) return; if(curOpen) { PrintVerbose("Open trade found, not registering new trade. Table: " + currentTableName + ", Pattern: " + signal.pattern); return; } // Register a new trade if no duplicates, outdated, or open trades were found above RegisterSignal(signal.year, signal.month, signal.day, signal.DOW, signal.hour, signal.minutes, currentTableName, signal.pattern, signal.direction, signal.entryPrice, 0.0, "NA", signal.netVote); PrintVerbose("Registered new trade in table: " + currentTableName + ", Pattern: " + signal.pattern + ", Direction: " + signal.direction); } //+------------------------------------------------------------------+ //| yyyymmddhhmm as a number - the ordering key the targeted DB | //| lookups compare on (matches the SQL expression they compute) | //+------------------------------------------------------------------+ long SignalTimeKey(const int year, const int month, const int day, const int hour, const int minutes) { return ((((long)year * 100 + month) * 100 + day) * 100 + hour) * 100 + minutes; } //+------------------------------------------------------------------+ //| ONE LINE, TOP-RIGHT: the vote that is actually being tested. | //+------------------------------------------------------------------+ void CExpertSignalCustom::UpdateVoteReadout(const double vote, const int voters, const int neutrals, const bool prospective) { double mag = MathAbs(vote); //--- m_votePeak is still tracked (Signal_ThresholdOpen tuning still wants it available //--- programmatically) but no longer printed here - see the trimmed format below //--- (user request 2026-08-25: drop peak/need/armed from the live line). if(MathIsValidNumber(mag) && mag > m_votePeak) m_votePeak = mag; //--- A PROSPECTIVE vote can never be a trade, however high it reads: it is a recomputation between //--- bars, not the bar's own decision. Saying "-> TRADE" on a number that cannot place an order //--- would be the exact overstatement this readout exists to prevent. bool fires = (mag >= m_threshold_open) && (voters > 0) && !prospective && (vote == 0.0 || WarriorDirectionAllows(vote > 0.0)); //--- DEPLOYMENT IS A PROPERTY OF THE MODELS, NOT OF WHICH CODE PATH PRODUCED THIS NUMBER. The two //--- were conflated: `prospective` alone drove the verdict word, so a fully deployed ensemble on a //--- bar where every member abstained - which is precisely when the prospective path runs - was //--- told it was still training, directly under four member lines saying "Live". bool deployed = WarriorChartModelsDeployed(); //--- THE HEADLINE WORD IS THE DECISION, NOT THE LEAN (user request 2026-08-19). bool clears = (voters > 0) && (vote != 0.0) && (mag >= m_threshold_open); string dir = (voters <= 0 && neutrals <= 0) ? "--" : (clears ? (vote > 0.0 ? "BUY" : "SELL") : "NEUTRAL"); //--- TWO STATES ONLY (user request 2026-08-25: drop the "armed (bar still open)" middle state). //--- A prospective vote on a deployed model now simply reads "no trade" like any other bar that //--- did not clear - fires is already forced false while prospective, so this falls out for free. string verdict = !deployed ? "-> training, not tradable yet" : (fires ? "-> TRADE" : "-> no trade"); //--- "2 vote/2 flat" rather than a bare count: which members are Neutral is half of what the //--- label is watched for during training. string who = (neutrals >= 0) ? StringFormat("%d vote/%d flat", voters, neutrals) : StringFormat("%d voter(s)", voters); //--- Word, not colour, carries the verdict now that this line lives in the plain-text status //--- label instead of its own chart object: "verdict" above already says TRADE/no trade/training. g_liveVoteLine = StringFormat("Live vote: %s %+5.1f%% %s %s", dir, vote, who, verdict); } //+------------------------------------------------------------------+ //| Repaint the readout from the CURRENT prospective vote. | //+------------------------------------------------------------------+ void CExpertSignalCustom::RefreshVoteReadout(void) { int total = m_filters.Total(); if(total <= 0) return; // leaf filter: the readout belongs to the aggregate alone if(m_lastLiveVoters > 0) return; // a real vote is on display; it owns the label until the next bar double pNum = 0.0, pDen = 0.0; int pVoters = 0, pFlats = 0; for(int i = 0; i < total; i++) { long mask = ((long)1) << i; if((m_ignore & mask) != 0) continue; CExpertSignalCustom *f = m_filters.At(i); if(f == NULL) continue; double pv = 0.0, pw = 0.0; if(!f.ProspectiveVote(pv, pw) || pw <= 0.0) continue; //--- CONSENSUS: the weight lands in the denominator for every model WITH a decision - a //--- Neutral dilutes the mean exactly as it does in the live vote and the overlay, so the //--- label, the arrows and the trade all move together. pDen += pw; if(pv == 0.0) { pFlats++; // has a decision, and it is Neutral: dilutes the mean, shows in the count continue; } pVoters++; pNum += ((m_invert & mask) != 0) ? -pv : pv; } if(pVoters + pFlats <= 0) return; // nothing to say yet; leave whatever the label holds UpdateVoteReadout((pDen > 0.0) ? (pNum / pDen) : 0.0, pVoters, pFlats, true); } //+------------------------------------------------------------------+ //| ARM the historical rebuild of the filtered view. | //| | //| Called at init and again whenever an era ends, because an era end | //| is exactly when the answer changes: the nets' weights moved, and | //| RankTiersFromOos() has just re-derived every tier's vote weight | //| from that era's holdout. A reconstruction built from the previous | //| era's weights is a picture of a model that no longer exists. | //+------------------------------------------------------------------+ void CExpertSignalCustom::StartFilteredOverlay(void) { if(DrawUnfilteredSignals) return; // raw view: the per-model layer owns the chart, nothing to reconstruct int barsAvail = Bars(m_symbol.Name(), m_period); if(barsAvail <= 300) return; //--- Same bound the "Show signals" rescan uses, for the same reason: full history is not free and //--- the terminal's own "Max bars in chart" makes anything past it undrawable anyway. int span = MathMin(SIGNAL_RESCAN_LOOKBACK_BARS, barsAvail); //--- BOTH BOUNDS ARE SERIES INDICES - 0 is the newest bar and the index counts BACKWARDS in //--- time. m_overlayIndex = MathMin(span, barsAvail - 150); //--- Stop at 2, not 0: bar 0 is still forming and bar 1 is the decision bar the FORWARD path //--- owns. The handover-time check inside the sweep covers this too, belt and braces. m_overlayStopIndex = 2; if(m_overlayIndex < m_overlayStopIndex) return; // not enough history past the warm-up tail to reconstruct anything //--- Latch the handover point ONCE. On later rebuilds the cutoff must stay where the EA actually //--- took over, not creep forward to "now" and start overwriting real decisions with guesses. if(m_overlayLiveCutoff == 0) m_overlayLiveCutoff = iTime(m_symbol.Name(), m_period, 0); m_overlaySweptBars = 0; m_overlayHadDataBars = 0; m_overlaySnapNoWeightBars = 0; m_overlayVotedBars = 0; m_overlayDrawn = 0; m_overlayVotedBuy = 0; m_overlayVotedSell = 0; m_overlayNmsLastBuyIdx = -1; m_overlayNmsLastSellIdx = -1; m_overlayNmsKeptIdx = -1; m_overlayNmsKeptBuy = false; m_overlayNmsKeptNet = 0.0; m_overlayBestNet = 0.0; m_overlayVoteFired = 0; m_overlayVoteWins = 0; m_overlayScoreReady = false; //--- The readout's peak resets HERE, at the same regime boundary that resets the census: tier //--- weights have just been re-derived, and a peak attained under the previous weights is not //--- comparable to anything the new weights can produce. m_votePeak = 0.0; //--- The sweep records what it drew so the prune below can re-walk it in FORWARD time order. m_overlayVoteCount = 0; ArrayResize(m_overlayVoteTime, 0); ArrayResize(m_overlayVoteBuy, 0); m_overlayPending = true; } //+------------------------------------------------------------------+ //| THE VOTE ON A HISTORICAL BAR - one aggregation rule, one divisor. | //| | //| This is the replay half of Direction(). It is separate for ONE | //| reason: Direction() is not a query, it is a transaction. It | //| journals DB rows, draws raw arrows, folds the result into an | //| intra-second averaging window, consumes one-shot per-filter vote | //| state and refreshes the live readout. Every one of those is | //| wrong on a bar from three weeks ago, which is why the classic | //| replay below has to bracket its Direction() call in a six-field | //| save/restore - that bracket IS the evidence, and it goes away | //| when Direction() is split into a pure vote plus its side effects. | //| | //| What must NOT differ between here and live is the arithmetic, and | //| it did: this loop used ModuleWeight() as the divisor while live | //| uses VoteCapableWeight(). A meta head (structurally incapable of | //| voting) and an untrained member both return 0 from the latter and | //| their full weight from the former, so every reconstructed vote | //| was shrunk by members that could never agree. The comment on the | //| old line even said "capable weight" while the code said | //| ModuleWeight - read the code for the value, the comment for the | //| why, and when they disagree the code is what shipped. | //| | //| capableOut returns the divisor so the caller can tell "nobody had | //| anything to say about this bar" (0) from "the vote was neutral". | //+------------------------------------------------------------------+ double CExpertSignalCustom::HistoricalNetVote(const int idx, double &capableOut, bool &hadSnapshotOut) { SVoteAccumulator vote; hadSnapshotOut = false; int total = m_filters.Total(); for(int i = 0; i < total; i++) { long mask = ((long)1) << i; if((m_ignore & mask) != 0) continue; CExpertSignalCustom *filter = m_filters.At(i); if(filter == NULL) continue; double contribution = 0.0; bool hasData = false; if(filter.IsAIFilter()) { //--- ERA-END SNAPSHOT, not the live cache, and the difference was a chart that flickered //--- between populated and blank. hasData = filter.SnapshotVoteAt(idx, contribution); } else if(filter.GetPatternCount() <= 0) continue; // veto filter (news/session/risk guard): no vote, no replay else { //--- Replay, with the live journaling state saved across it - see SaveVoteState(). string pl, ps; double nv; int lw, sw, fd; filter.SaveVoteState(pl, ps, nv, lw, sw, fd); filter.EvalShift(idx); filter.Direction(); filter.EvalShift(0); double signedWeight = (double)(filter.LastLongWeight() - filter.LastShortWeight()); filter.RestoreVoteState(pl, ps, nv, lw, sw, fd); contribution = filter.ModuleWeight() * signedWeight; hasData = true; // a ladder always answers; "no match" is an abstention } //--- NO SNAPSHOT IS NOT AN ABSTENTION. An abstainer looked at the bar and said nothing, and //--- must dilute the consensus; a member with no snapshot has not looked, and must not be in //--- the divisor at all - see g_warriorOverlayReadyMask. if(!hasData) continue; //--- SEPARATE FROM THE DIVISOR ON PURPOSE. A member can have a snapshot and still contribute //--- nothing, because ReconstructionWeight() is zero for a member with no demonstrated edge. //--- The census used to collapse the two and report "not one bar had a snapshot" for both, //--- which sent three separate investigations after a publication fault that did not exist. hadSnapshotOut = true; if((m_invert & mask) != 0) contribution = -contribution; //--- ReconstructionWeight, NOT VoteCapableWeight - see its declaration. This is the ONLY //--- divisor in the file that is allowed to differ from live, and it differs in exactly one //--- way: it does not require the training run to have converged. vote.Add(contribution, filter.ReconstructionWeight()); } capableOut = vote.capable; return vote.Net(); } //+------------------------------------------------------------------+ //| RECONSTRUCT what the filtered view would have shown, one chunk | //| per call. Returns true while there is more to do. | //+------------------------------------------------------------------+ bool CExpertSignalCustom::AdvanceFilteredOverlay(const int barBudget) { if(!m_overlayPending) return false; if(DrawUnfilteredSignals) // switched to the raw view mid-sweep { m_overlayPending = false; return false; } int processed = 0; while(m_overlayIndex >= m_overlayStopIndex && processed < barBudget) { //--- STOP CHECK PER BAR, not per slice. The slice bound alone is not a stop check: it bounds //--- throughput, not latency. if(IsStopped()) { m_overlayPending = false; return false; } int idx = m_overlayIndex--; processed++; datetime bt = iTime(m_symbol.Name(), m_period, idx); //--- At or past the handover: the forward path owns these bars. Leave whatever it decided. if(bt <= 0 || (m_overlayLiveCutoff > 0 && bt >= m_overlayLiveCutoff)) continue; double den = 0.0; bool hadSnapshot = false; double net = HistoricalNetVote(idx, den, hadSnapshot); if(den <= 0.0 && hadSnapshot) m_overlaySnapNoWeightBars++; //--- Census for the completion line below - see it for why a blank chart has to be able to //--- say WHY it is blank. The buy/sell split exists because "the vote leans one way" must be //--- checkable from the log, not inferred from squinting at arrow colours. if(den > 0.0) m_overlayHadDataBars++; if(den > 0.0 && net != 0.0) { m_overlayVotedBars++; if(net > 0.0) m_overlayVotedBuy++; if(net < 0.0) m_overlayVotedSell++; if(MathAbs(net) > m_overlayBestNet) m_overlayBestNet = MathAbs(net); } m_overlaySweptBars++; //--- NO DATA IS NOT A VERDICT. A bar where no member had a snapshot entry (den == 0) says //--- nothing about the vote there - deleting its arrow on that basis is how the draw/wipe //--- cycle above erased whole sweeps. if(den <= 0.0) continue; //--- The direction policy (LONG_ONLY/SHORT_ONLY) gates the reconstruction exactly as it //--- gates CheckOpenLong/Short live: a blocked side falls into //--- the else branch below - a real verdict that deletes any standing arrow - because that //--- trade would not have happened. if(MathAbs(net) >= m_threshold_open && WarriorDirectionAllows(net > 0.0)) { bool isBuy = (net > 0.0); //--- SCORE BEFORE DECLUSTERING: every bar in this branch is a call the vote made under the //--- live decision rule, whether or not NMS keeps its arrow. A bar whose label is not yet //--- resolved (pivot pair uncommitted) is excluded from both counts, same as the replay pass. ENUM_SIGNAL overlayTruth = Neutral; if(OverlayTruthAt(idx, overlayTruth)) { m_overlayVoteFired++; if((isBuy && overlayTruth == Buy) || (!isBuy && overlayTruth == Sell)) m_overlayVoteWins++; } //--- DECLUSTER, same three rules as the per-member arrows (PruneDirectionalClusters) and //--- for the same reason: consecutive same-direction bars are ONE setup, and a carpet of //--- arrows on every bar of a trend (observed 2026-08-19, "arrows on every bars") reads as //--- noise, not signal. int lastSame = isBuy ? m_overlayNmsLastBuyIdx : m_overlayNmsLastSellIdx; bool sameRun = (lastSame >= 0 && (lastSame - idx) <= OVERLAY_NMS_WINDOW); if(isBuy) m_overlayNmsLastBuyIdx = idx; else m_overlayNmsLastSellIdx = idx; if(sameRun) { WarriorDeleteSignalMark(SIG_VOTE_PREFIX + TimeToString(bt)); continue; } if(m_overlayNmsKeptIdx >= 0 && (m_overlayNmsKeptIdx - idx) <= OVERLAY_NMS_WINDOW && m_overlayNmsKeptBuy != isBuy) { if(MathAbs(net) <= m_overlayNmsKeptNet) { WarriorDeleteSignalMark(SIG_VOTE_PREFIX + TimeToString(bt)); continue; // weaker side of a flicker at one turn zone } //--- this bar is stronger: the earlier opposite arrow is the flicker - take it down datetime kt = iTime(m_symbol.Name(), m_period, m_overlayNmsKeptIdx); if(kt > 0) WarriorDeleteSignalMark(SIG_VOTE_PREFIX + TimeToString(kt)); } m_overlayNmsKeptIdx = idx; m_overlayNmsKeptBuy = isBuy; m_overlayNmsKeptNet = MathAbs(net); //--- Trigger price, same convention as the live mark above. double price = iClose(m_symbol.Name(), m_period, idx); //--- Marked as a reconstruction IN THE TOOLTIP, not just in a comment. Someone reading two //--- arrows either side of the handover has to be able to tell which one is a record and //--- which is a replay, and the chart is the only place they will look. m_overlayDrawn++; //--- Recorded, not declustered here: this sweep runs NEWEST->OLDEST, so applying a cooldown //--- inline would keep the NEWEST bar of a cluster while the live path keeps the FIRST. The //--- prune at completion re-walks this list backwards - forward in time - so the two agree. ArrayResize(m_overlayVoteTime, m_overlayVoteCount + 1); // oldest-first, see the decl ArrayResize(m_overlayVoteBuy, m_overlayVoteCount + 1); m_overlayVoteTime[m_overlayVoteCount] = bt; m_overlayVoteBuy[m_overlayVoteCount] = isBuy; m_overlayVoteCount++; WarriorPlotSignalLevel(SIG_VOTE_PREFIX + TimeToString(bt), bt, (ENUM_TIMEFRAMES)m_period, price, isBuy, true, StringFormat("would trade %s @ %s | confidence %.1f%% >= %.1f%% |" " reconstructed (vote only - order validation not replayed)", (isBuy ? "BUY" : "SELL"), DoubleToString(price, m_symbol.Digits()), MathAbs(net), m_threshold_open)); } else WarriorDeleteSignalMark(SIG_VOTE_PREFIX + TimeToString(bt)); } if(m_overlayIndex < m_overlayStopIndex) { m_overlayPending = false; m_overlayScoreReady = true; //--- VOTE-LEVEL COOLDOWN, drawn side - RECONCILED OVER THE CHART, not over this sweep's record. //--- //--- The record-based version was individually correct and still left clusters, because it is //--- not the only producer. The persisted-arrow RESTORE thins its own list from its own state, //--- this sweep thins its own list from its own state, and the LIVE gate marks the current bar //--- from a third. Each pass spaces its OWN survivors 30 bars apart; interleaved on one chart //--- the union can sit 1 bar apart. Measured on the saved files: 148 same-side pairs under 30 //--- bars on XTIUSD, minimum gap ZERO, while every producer's own log said it had thinned //--- correctly. //--- //--- So the authority is the CHART ITSELF. Whatever drew an arrow, this runs last and enforces //--- one rule over the actual object set. OBJ_TREND only - a mark is a line AND an arrow, and //--- the line is the canonical half (Snapshot() uses the same test for the same reason). int coolWin = WarriorSignalCooldownBars(); if(coolWin > 0) { long coolGap = (long)coolWin * PeriodSeconds(); datetime seen[]; int found = 0; int totalObj = ObjectsTotal(0); ArrayResize(seen, totalObj); for(int oi = 0; oi < totalObj; oi++) { string on = ObjectName(0, oi); if(StringFind(on, SIG_VOTE_PREFIX) != 0) continue; if(ObjectGetInteger(0, on, OBJPROP_TYPE) != OBJ_TREND) continue; seen[found++] = (datetime)ObjectGetInteger(0, on, OBJPROP_TIME, 0); } ArrayResize(seen, found); if(found > 1) { //--- Object order is not time order. Sorting is what makes the forward walk below mean //--- anything - an unsorted walk yields negative gaps, and a negative gap is inside any //--- window, which is how a prune once deleted 272 of 273 arrows. ArraySort(seen); datetime lastKept = 0; int killed = 0; for(int v = 0; v < found; v++) { long gap = (long)(seen[v] - lastKept); if(lastKept != 0 && gap > 0 && gap <= coolGap) { WarriorDeleteSignalMark(SIG_VOTE_PREFIX + TimeToString(seen[v])); killed++; continue; } lastKept = seen[v]; } if(killed > 0) { m_overlayDrawn -= killed; if(m_overlayDrawn < 0) m_overlayDrawn = 0; PrintFormat("Filtered view: signal cooldown (%d bars) RECONCILED the chart - removed %d" " of %d vote arrow(s) that survived their own producer's thinning. The" " survivors are the FIRST bar of each cluster, matching the live gate.", coolWin, killed, found); } } } //--- SAY WHY THE CHART LOOKS THE WAY IT DOES. So the sweep reports its own arithmetic: how //--- many bars it looked at, how many had any voter at all, the strongest vote it saw, and //--- the bar that vote had to clear. bool censusDue = VerboseMode || m_overlayDrawn != m_overlayLastLogDrawn || MathAbs(m_overlayBestNet - m_overlayLastLogBest) >= 2.0 || m_overlaySkippedLogs >= 9; if(!censusDue) m_overlaySkippedLogs++; else { m_overlaySkippedLogs = 0; m_overlayLastLogDrawn = m_overlayDrawn; m_overlayLastLogBest = m_overlayBestNet; //--- THREE CAUSES, THREE SENTENCES. Reported off hadData/voted/drawn rather than off //--- votedBars alone, which used to claim "no member has a completed era yet" for all three. string why; if(m_overlayHadDataBars == 0 && m_overlaySnapNoWeightBars > 0) why = StringFormat(" %d bar(s) DID carry a member snapshot and the divisor was still zero:" " every member that looked was ruled NO SKILL by HasDemonstratedEdge()" " (certified precision not above its chance rate), so ReconstructionWeight()" " returned 0 for all of them. This is a SKILL verdict, not a publication" " fault - check each member's certified precision vs chance rate above.", m_overlaySnapNoWeightBars); else if(m_overlayHadDataBars == 0) why = " NOT ONE of those bars had a single member snapshot, so the divisor was zero" " everywhere and no vote could be formed. This is NOT a threshold outcome: it means" " no enrolled member has published m_overlaySigSnap (filled by RankTiersFromOos at" " pass-3 completion), or the snapshot it published holds -2.0 across this window -" " check the era numbers above: if members are past era 1 this is an index/publication" " fault, not a young run."; else if(m_overlayVotedBars == 0) why = StringFormat(" %d bar(s) DID carry a snapshot and every one of them netted exactly" " zero - the members looked and abstained. That is a calibration" " outcome, not a drawing fault.", m_overlayHadDataBars); else if(m_overlayDrawn == 0) why = " The models voted but never strongly enough; this is the vote failing the" " bar, not the drawing failing."; else why = ""; Print(StringFormat("Filtered view: swept %d bar(s), %d had a snapshot, %d had a voter" " (%d buy / %d sell), drew %d arrow(s). Strongest vote %.1f%% against a" " %.1f%% threshold.%s", m_overlaySweptBars, m_overlayHadDataBars, m_overlayVotedBars, m_overlayVotedBuy, m_overlayVotedSell, m_overlayDrawn, m_overlayBestNet, m_threshold_open, why)); } return false; } return true; } //+------------------------------------------------------------------+ //| The truth a reconstructed vote is scored against, asked of the | //| FIRST AI member: the swing-pivot label is a pure function of the | //| shared chart series (ZigZag/Close/ATR over the same symbol and | //| period every member reads), so which member answers is | //| irrelevant - and asking exactly one keeps the sweep's cost flat | //| in the member count. | //+------------------------------------------------------------------+ bool CExpertSignalCustom::OverlayTruthAt(const int idx, ENUM_SIGNAL &truth) { truth = Neutral; int total = m_filters.Total(); for(int i = 0; i < total; i++) { CExpertSignalCustom *filter = m_filters.At(i); if(filter == NULL || !filter.IsAIFilter()) continue; return filter.ReplayTruthAt(idx, truth); } return false; } //+------------------------------------------------------------------+ //| One-shot harvest of the completed sweep's combined-vote score. | //| Consuming: the ready flag drops on the first read, so one sweep | //| can never be counted into the ensemble record twice. | //+------------------------------------------------------------------+ bool CExpertSignalCustom::TakeOverlayVoteScore(long &fired, long &wins) { if(!m_overlayScoreReady) return false; m_overlayScoreReady = false; fired = m_overlayVoteFired; wins = m_overlayVoteWins; return true; } //+------------------------------------------------------------------+ //| The bar timestamp a buffered signal carries, as a datetime. | //+------------------------------------------------------------------+ datetime SignalTime(const SignalInfo &signal) { MqlDateTime t; t.year = signal.year; t.mon = signal.month; t.day = signal.day; t.hour = signal.hour; t.min = signal.minutes; t.sec = 0; return StructToTime(t); } //+------------------------------------------------------------------+ //| Order buffered signals oldest-first, ready for the DB write. | //+------------------------------------------------------------------+ void SortSignalsByTime(SignalInfo &signals[]) { int n = ArraySize(signals); if(n < 2) return; datetime keys[]; ArrayResize(keys, n); for(int i = 0; i < n; i++) keys[i] = SignalTime(signals[i]); for(int i = 1; i < n; i++) { SignalInfo item = signals[i]; datetime key = keys[i]; int j = i - 1; while(j >= 0 && keys[j] > key) { signals[j + 1] = signals[j]; keys[j + 1] = keys[j]; j--; } signals[j + 1] = item; keys[j + 1] = key; } } //+------------------------------------------------------------------+ //| Process the signal and update trades | //+------------------------------------------------------------------+ void CExpertSignalCustom::ProcessBufferedSignals() { // Sort the signals array by datetime before processing SortSignalsByTime(signalBuffer); if(!dbm.OpenDatabase()) { Print("Failed to open database."); return; } if(!dbm.BeginTransaction()) { Print(__FUNCTION__ + ": Failed to begin database transaction, " + IntegerToString(ArraySize(signalBuffer)) + " buffered signal(s) left pending for retry next cycle."); return; } for(int i = 0; i < ArraySize(signalBuffer); i++) { PrintVerbose("Processing signal " + IntegerToString(i + 1) + " of " + IntegerToString(ArraySize(signalBuffer))); ProcessSignal(signalBuffer[i]); } if(!dbm.CommitTransaction()) { Print(__FUNCTION__ + ": Failed to commit the transaction to the database, rolling back. " + IntegerToString(ArraySize(signalBuffer)) + " buffered signal(s) left pending for retry next cycle."); dbm.RollbackTransaction(); return; } ArrayResize(signalBuffer, 0); PrintVerbose("Signal buffer cleared after processing."); // NOTE: does NOT close dbm here - the caller (CExpertCustom::OnTimer) opens the shared // connection once and also calls UpdateSignalsWeights() right after this returns; closing it // here made UpdateSignalsWeights() silently fail (BeginTransaction on a closed handle) in every // live/demo run (IsBacktesting only skipped this close in the tester, masking the bug there). // The opener (OnTimer) now owns closing it. } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ void CExpertSignalCustom::DeleteOldestEntry(string tableName) { dbm.DeleteOldestEntry(tableName); // failure is already logged by the DB layer } //+------------------------------------------------------------------+ //| Register a signal in the database | //+------------------------------------------------------------------+ void CExpertSignalCustom::RegisterSignal(int year, int month, int day, int DOW, int hour, int minutes, string tableName, string pattern, string direction, double entryPrice, double exitPrice, string result, double netVote) { string Columns[] = {"year", "month", "day", "dayOfWeek", "hour", "minutes", "pattern", "direction", "entryPrice", "exitPrice", "result", "netVote"}; string valArr[] = {IntegerToString(year), IntegerToString(month), IntegerToString(day), IntegerToString(DOW), IntegerToString(hour), IntegerToString(minutes), pattern, direction, DoubleToString(entryPrice, Digits()), DoubleToString(exitPrice, Digits()), result, DoubleToString(netVote, 2)}; if(dbm.InsertTradeRecord(tableName, Columns, valArr)) { PrintVerbose("Successfully registered signal in table: " + tableName); } else { Print("Failed to register signal in table: " + tableName); } } //+------------------------------------------------------------------+ //| Update a trade record in the database | //+------------------------------------------------------------------+ void CExpertSignalCustom::UpdateTradeRecordInDatabase(string tableName, TradeRecord &tradeRecord) { string columns[] = { "exitPrice", "result" }; string values[] = { DoubleToString(tradeRecord.exitPrice, Digits()), tradeRecord.result }; if(dbm.UpdateTradeRecord(tableName, columns, values, tradeRecord.pattern, tradeRecord.direction)) { PrintVerbose("Successfully updated trade record in table: " + tableName); } else { Print("Failed to update trade record in table: " + tableName + " for pattern " + tradeRecord.pattern + " and direction " + tradeRecord.direction); } } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ bool CExpertSignalCustom::UpdateSignalsWeights(void) { if(!dbm.BeginTransaction()) return(false); int total = m_filters.Total(); double sumModuleWeight = 0.0; int weightedFilterCount = 0; //--- Rows at or after 'now' can only exist in a resumed/mixed database and must not leak into //--- weights mid-backtest; the bound is applied inside SQLite (see FetchWinLossCounts). It replaces //--- the tester-only array trim the old full-table fetch did here, and is harmless live: a row's //--- open time is never in the future. MqlDateTime brokerNow; TimeCurrent(brokerNow); // broker clock, matching the row stamps since dbVersion 4.0 long nowKey = SignalTimeKey(brokerNow.year, brokerNow.mon, brokerNow.day, brokerNow.hour, brokerNow.min); for(int i = 0; i < total; i++) { CExpertSignalCustom *filter = m_filters.At(i); //--- check pointer if(filter == NULL) continue; string filterID = filter.GetFilterID(); if(filterID == "NULL") continue; int patternCount = filter.GetPatternCount(); if(patternCount <= 0 || patternCount == NULL) continue; int totalWinRate = 0; int validPatternCount = 0; //--- POOL PASS. Counting is a pair of SQL aggregates per table (no rows materialize), so the //--- extra pass costs the same order as the scoring pass below. int poolWins = 0, poolTotal = 0; for(int j = 0; j < patternCount; j++) { string pPattern = PatternName(j); int pw = 0, pl = 0; if(dbm.FetchWinLossCounts(PatternTableName(filterID, pPattern, "Buy"), nowKey, pw, pl)) { poolWins += pw; poolTotal += pw + pl; } pw = 0; pl = 0; if(dbm.FetchWinLossCounts(PatternTableName(filterID, pPattern, "Sell"), nowKey, pw, pl)) { poolWins += pw; poolTotal += pw + pl; } } double poolPct = (poolTotal > 0) ? (100.0 * poolWins / poolTotal) : -1.0; //--- One MIN_TRADES_FOR_WIN_RATE-worth of pseudo-trades: a tier measured at exactly the //--- minimum ends up half pool / half its own evidence, and the pull halves again with every //--- doubling of its sample. int poolWeight = (poolTotal > 0) ? MIN_TRADES_FOR_WIN_RATE : 0; for(int j = 0; j < patternCount; j++) { // Aggregate outcome counts, computed inside SQLite - no rows materialize into MQL arrays, // so this cycle's cost is flat in table size (the same fix as ProcessSignal's lookups). string pattern = PatternName(j); string tableNameBuy = PatternTableName(filterID, pattern, "Buy"); string tableNameSell = PatternTableName(filterID, pattern, "Sell"); int winsBuy = 0, lossesBuy = 0, winsSell = 0, lossesSell = 0; if(!dbm.FetchWinLossCounts(tableNameBuy, nowKey, winsBuy, lossesBuy)) { Print(__FUNCTION__ + " Failed to count outcomes in " + tableNameBuy); continue; } if(!dbm.FetchWinLossCounts(tableNameSell, nowKey, winsSell, lossesSell)) { Print(__FUNCTION__ + " Failed to count outcomes in " + tableNameSell); continue; } int winRateBuy = WinRateFromCounts(winsBuy, lossesBuy, poolPct, poolWeight); int winRateSell = WinRateFromCounts(winsSell, lossesSell, poolPct, poolWeight); // Skip sides with insufficient samples instead of averaging in the sentinel if(winRateBuy == NO_DATA_WIN_RATE && winRateSell == NO_DATA_WIN_RATE) continue; int combinedWinRate = (winRateBuy == NO_DATA_WIN_RATE) ? winRateSell : (winRateSell == NO_DATA_WIN_RATE) ? winRateBuy : (winRateBuy + winRateSell) / 2; if(combinedWinRate >= 0 && combinedWinRate <= 100) { filter.ApplyPatternWeight(j, combinedWinRate); totalWinRate += combinedWinRate; validPatternCount++; PrintVerbose("Applied " + filterID + " " + pattern + " Weight " + IntegerToString(combinedWinRate)); } } // Calculate the average win rate for valid patterns double averageWinRate = validPatternCount > 0 ? (totalWinRate) / validPatternCount : 0.0; // Normalize the average win rate to the range 0 to 1 double normalizedWinRate = averageWinRate / 100.0; // Round the normalized win rate to the nearest 0.05 normalizedWinRate = MathRound(normalizedWinRate * 10) / 10.0; // Ensure the rounded value is within 0 to 1 normalizedWinRate = MathMax(0, MathMin(normalizedWinRate, 1)); // Apply the main weight based on the normalized and rounded win rate double moduleWeight = normalizedWinRate; //--- ...but not over a self-ranking filter. if(moduleWeight > 0 && moduleWeight <= 1 && !filter.SelfRanked()) { filter.Weight(moduleWeight); PrintVerbose("Applied " + filterID + " Main Weight " + DoubleToString(moduleWeight, 2)); } if(validPatternCount > 0) { sumModuleWeight += normalizedWinRate; weightedFilterCount++; } } // Average DB win-rate across the filters that had enough samples to produce one. RECORDED ONLY - // it is journalled per trade and never read back into a decision (the Confidence_Source input that // let it size stops and lots was removed 2026-08-25). Note the shape it actually has: it is a mean // of pattern win rates used for RANKING filters against one another, which is not the same // quantity as "the probability this particular trade wins", and was never validated as one. m_dbConfidence = weightedFilterCount > 0 ? sumModuleWeight / weightedFilterCount : 0.0; if(dbm.CommitTransaction()) return true; else return(false); } //+------------------------------------------------------------------+ //| Win rate from SQL-side outcome counts (see FetchWinLossCounts) | //+------------------------------------------------------------------+ int CExpertSignalCustom::WinRateFromCounts(const int wins, const int losses, const double priorPct, const int priorWeight) { int totalTrades = wins + losses; if(totalTrades < MIN_TRADES_FOR_WIN_RATE) return NO_DATA_WIN_RATE; //--- Shrunk toward the pool this ladder belongs to - the caller supplies it, and a caller with //--- no pool passes priorWeight 0 for the raw ratio. return NormalizeWinRate(ShrunkRatePct((double)wins, (double)totalTrades, priorPct, (double)priorWeight)); } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ int CExpertSignalCustom::NormalizeWinRate(double winRate) { return (int)MathRound(winRate / 10) * 10; // Round to the nearest 10 } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ void CExpertSignalCustom::OnTickHandler(void) { //--- WHOLE TREE, not just the voters: OnTickHandler is what drives each AI signal's training, //--- and a gate left out of it silently stops learning. int total = ChildSignalCount(); for(int i = 0; i < total; i++) { CExpertSignalCustom *filter = ChildSignalAt(i); //--- check pointer if(filter == NULL) continue; //--- NO GetFilterID() == "NULL" TEST HERE any more. CSignalNewsFilter, CSignalSessionFilter //--- and CSignalRiskGuard never set an id, so all three were silently skipped here and in //--- OnChartEventHandler below. filter.OnTickHandler(); } } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ void CExpertSignalCustom::OnChartEventHandler(const int id, const long &lparam, const double &dparam, const string &sparam) { int total = ChildSignalCount(); // whole tree - a gate has panel controls like any signal for(int i = 0; i < total; i++) { CExpertSignalCustom *filter = ChildSignalAt(i); //--- check pointer if(filter == NULL) continue; //--- no id test - see OnTickHandler above. filter.OnChartEventHandler(id, lparam, dparam, sparam); } } //+------------------------------------------------------------------+ //| Hands a panel command to this signal and every filter under it, | //| and reports how many acted on it. | //+------------------------------------------------------------------+ int CExpertSignalCustom::DispatchSignalCommand(const ENUM_SIGNAL_COMMAND cmd) { int acted = OnSignalCommand(cmd) ? 1 : 0; int total = ChildSignalCount(); // whole tree - a panel command must reach a gate too for(int i = 0; i < total; i++) { CExpertSignalCustom *filter = ChildSignalAt(i); if(filter == NULL) continue; acted += filter.DispatchSignalCommand(cmd); } return acted; } //+------------------------------------------------------------------+ //| How many signals in this subtree carry a given trait. | //+------------------------------------------------------------------+ int CExpertSignalCustom::CountSignalTrait(const ENUM_SIGNAL_TRAIT trait) { int n = HasSignalTrait(trait) ? 1 : 0; int total = ChildSignalCount(); // the panel acts on the whole tree, so it must count it for(int i = 0; i < total; i++) { CExpertSignalCustom *filter = ChildSignalAt(i); if(filter == NULL) continue; n += filter.CountSignalTrait(trait); } return n; } //+------------------------------------------------------------------+