//+------------------------------------------------------------------+ //| Warrior_EA | //| AnimateDread | //| | //+------------------------------------------------------------------+ #include #include "..\System\NewBar.mqh" #include "..\Structures\tradeRecordStructure.mqh" #include "..\Structures\signalInfoStructure.mqh" #include "..\Variables\ConfidenceBridge.mqh" #include "..\System\TradeChecks.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. | //| | //| It moved because classic signals now draw too. This header is the | //| nearest common ancestor: ExpertSignalAIBase.mqh includes it | //| (CExpertSignalAIBase derives from CExpertSignalCustom) and so do | //| SignalMA/RSI/MACD/Ichimoku, whereas the AI header is pulled in | //| later in Warrior_EA.mq5's include order and is invisible from | //| here. Every arrow this EA draws - AI member, classic signal, or | //| the combined vote - is named from this one prefix, which is what | //| keeps WarriorChartPrefixes()'s bare-prefix purge covering all of | //| them without needing to know they exist. | //+------------------------------------------------------------------+ #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 LEVEL MARKS (2026-08-19, user request: "move from arrows on lows and highs to small //--- horizontal lines at the actual prices the entry/exit would trigger... dark green for buy, dark //--- red for sell"). Arrows sat on the candle's LOW (buy) / HIGH (sell), which is a price the trade //--- never touches - it read as a decoration rather than as a level. The mark is now a short //--- horizontal segment AT THE TRIGGER PRICE, so the chart shows where the order would actually go //--- on and can be eyeballed against the candles that follow. //--- //--- COLOUR NOW MEANS DIRECTION AND ONLY DIRECTION, on every layer. Layer is carried by WIDTH and //--- STYLE instead (the traded vote is solid and thick, a single model's opinion is thin and //--- dotted), which keeps the distinction the old palette drew - a model's opinion must never read //--- as a trade - while freeing colour to say one thing consistently. #define WARRIOR_SIG_BUY_COLOR clrDarkGreen #define WARRIOR_SIG_SELL_COLOR clrDarkRed //--- Half-width of the segment as a fraction of one bar, so the mark is "just a bit larger than the //--- candles" (1.3 bar widths total) at every timeframe without a per-timeframe table. #define WARRIOR_SIG_LEVEL_HALF_SPAN 0.65 //--- Direction token persisted in the .arrows sidecar. These were MT5 Wingdings arrow codes; the //--- objects are lines now and carry no code, so the value survives ONLY as a saved buy/sell flag //--- and is mapped to/from the object's colour at the chart boundary (SaveChartSignals / //--- AdvanceChartSignalRestore). Kept at the old numbers deliberately: existing sidecar files stay //--- readable, so nobody's arrow history is orphaned by a cosmetic change. #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. One bound, because they draw onto the same chart and a //--- reader comparing the raw and filtered views across the same span must be seeing the same span. //--- 5000 also comfortably exceeds the smallest "Max bars in chart" MT5 offers, past which nothing //--- can be drawn anyway. #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 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. | //| | //| A SHORT HORIZONTAL SEGMENT AT `price`, not an arrow beside the | //| candle: OBJ_TREND with both anchors at the same price and both | //| rays off, spanning WARRIOR_SIG_LEVEL_HALF_SPAN bars either side | //| of the bar's open time. `period` is passed rather than read from | //| _Period so the span is right even if a caller ever draws for a | //| timeframe other than the chart's. | //| | //| No ObjectFind() pre-check, for the reason CExpertSignalAIBase:: | //| DrawObject() documents at length: ObjectFind scans the entire | //| chart object list, so calling it per drawn mark makes a full | //| redraw O(n^2) in the mark count - the exact pattern that froze | //| the terminal once already. ObjectCreate returns false harmlessly | //| when the name exists, and re-applying the properties is precisely | //| what a refresh does. | //+------------------------------------------------------------------+ 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); ObjectSetInteger(0, name, OBJPROP_WIDTH, isTrade ? 2 : 1); 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 VOTE READOUT's own object namespace. Starts with "Warrior" so WarriorChartPrefixes()'s //--- catch-all already reaches it, but it is listed there EXPLICITLY as well, per that function's //--- own standing rule - the list has drifted twice and the catch-all exists to survive that, not to //--- excuse skipping the entry. #define VOTE_HUD_PREFIX "WarriorVoteHUD" //--- 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 //--- INTELLIGENT trade direction - the measured drift verdict, written by the label-cache //--- prebuild (Expert\AIBase\Labels.mqh, see the verdict block there for the statistics). //--- Chart-level globals because every ensemble member measures the IDENTICAL label distribution; //--- last writer writes the same value. BOTH until measured - the safe state, and the permanent //--- state on classic-only charts, which never build a label cache. TRADING_DIRECTION g_warriorDriftVerdict = BOTH; bool g_warriorDriftMeasured = false; //--- The one resolution point for the Trade direction input: INTELLIGENT defers to the measured //--- verdict, everything else is what it always was. Every gate - live entry, reconstruction, //--- HUD verdict - resolves through here so they cannot drift apart. TRADING_DIRECTION WarriorEffectiveDirection(void) { return (tradingdirection == DIRECTION_INTELLIGENT) ? g_warriorDriftVerdict : tradingdirection; } bool WarriorDirectionAllows(const bool isLong) { TRADING_DIRECTION d = WarriorEffectiveDirection(); return isLong ? (d != SHORT_ONLY) : (d != LONG_ONLY); } //--- META-LABELING GATE HOOK (2026-08-19, Meta_Labeling_Design.md S3). Non-NULL only when //--- Use_MetaLabeling created a meta head this run (set in InitializeSignal, cleared at every //--- re-init before signal creation). Declared here beside the other chart-level policy state so //--- CheckOpenPosition (below) and the ensemble era verdict (Expert\AIBase\Training.mqh) resolve //--- the SAME gate through the SAME pointer - the certified-equals-traded rule the direction //--- policy above already follows. Forward-declared because this sits ABOVE the class it points //--- at - the same pattern (and the same reason) as g_warriorEnsemble in ExpertSignalAIBase.mqh: //--- the chart-level policy state belongs together, and a pointer needs only the name of its type. class CExpertSignalCustom; CExpertSignalCustom *g_warriorMetaGate = NULL; //--- The symbol's own trading-session table, asked two questions (2026-08-19 user request: //--- "everything will be dynamic and self adapting to DST"). Both helpers read //--- SymbolInfoSessionTrade fresh on every call - nothing cached, nothing to go stale when the //--- broker moves the schedule or DST shifts it. //--- Is `now` (server time) inside any trading session of its weekday? Sessions come back as //--- seconds-from-midnight pairs; a day with no sessions (Saturday, most of Sunday) yields no //--- pairs and reads as closed. 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. //--- WAS 2.0, LOWERED TO 0.5 on 2026-07-31 when the stop moved off the swing anchor. At 2.0 it existed //--- because a swing-anchored stop could land arbitrarily close to the entry (a shallow pullback puts //--- the swing right at the fill), so the distance needed a floor unrelated to the chosen multiple. //--- An entry-anchored stop is exactly SL_Mode*ATR by construction and cannot collapse, so keeping the //--- floor at 2.0 would have quietly overridden SL_ATR_x1 to 2*ATR - making that input a lie AND //--- forcing TP >= 4*ATR just to clear what was then a 1:2 minimum-reward:risk rejection. That is the //--- same interaction that once rejected 100% of setups on every symbol (see TP_INTELLIGENT_BASE_RR). //--- The rejection filter itself was removed on 2026-08-09; this floor still matters, because it is //--- what stops SL_Mode from being silently overridden. #define MIN_SL_ATR_MULTIPLIER 0.5 //--- Underlying-int sentinel for the "Intelligent" SL/TP modes (STOP_LOSS_MODE::SL_INTELLIGENT / //--- TAKE_PROFIT_MODE::TP_INTELLIGENT, both -1 in Enumerations\InputEnums.mqh). Kept as a local macro //--- rather than referencing the enum name so this header stays independent of InputEnums.mqh's include //--- order, exactly like m_confidence_source being an int (see Variables\ConfidenceBridge.mqh). #define SL_INTELLIGENT_MODE (-1) #define TP_INTELLIGENT_MODE (-1) //--- 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. ENTRY_PREV_SWING is //--- unaffected and still uses the swing prices; that is why they are still computed here. //--- Intelligent (AI-confidence) SL/TP shaping, driven by EffectiveConfidence() (a 0..1 magnitude, see //--- CExpertSignalAIBase::AIConfidence/DBConfidence per Confidence_Source): //--- - SL starts SL_INTELLIGENT_BASE_MULT beyond the swing and TIGHTENS by up to AI_SL_TIGHTEN_FACTOR //--- (30%) as confidence -> 1: a high-conviction setup gets a tighter stop, a marginal one keeps the //--- full ATR cushion. Still floored at MIN_SL_ATR_MULTIPLIER above. //--- - TP is a multiple of THIS TRADE'S OWN RISK (the final entry-to-stop distance), not of ATR: it //--- starts at TP_INTELLIGENT_BASE_RR and WIDENS by up to AI_TP_WIDEN_FACTOR (+100%, i.e. 2x) as //--- confidence -> 1, so RR runs 2.5 (zero confidence) to 5.0 (full conviction). //--- WHY risk-relative and not ATR-relative: SL is swing-anchored PLUS padding, so its distance //--- grows with the swing gap, while an ATR-from-entry TP does not. Those two were decoupled when //--- TP moved off the opposite-swing anchor (commit 0f09588), and nothing re-checked the result //--- against the then-active minimum reward:risk: with confidence pinned at 0 (AI disabled - the shipped //--- default) the old TP_INTELLIGENT_BASE_MULT of 3.0 produced reward = 3*ATR against a risk that //--- MIN_SL_ATR_MULTIPLIER alone floors at 2*ATR, so `reward < 2.0*risk` was ALWAYS true and //--- OpenParams() rejected 100% of setups on every symbol and timeframe - the EA could not place a //--- single trade. Deriving TP from the realised risk restores the coupling the swing-anchored TP //--- used to provide. The 1:2 rejection filter that made this coupling load-bearing is gone as of //--- 2026-08-09, but the coupling is kept: a TP derived from the trade's own risk is the correct //--- shape regardless of whether anything downstream is checking the ratio. #define SL_INTELLIGENT_BASE_MULT 3.0 #define TP_INTELLIGENT_BASE_RR 2.5 #define AI_SL_TIGHTEN_FACTOR 0.3 #define AI_TP_WIDEN_FACTOR 1.0 //--- ENTRY_MULTIPLIER "Intelligent"/"Prev swing" sentinels (ENTRY_INTELLIGENT/ENTRY_PREV_SWING in //--- Enumerations\InputEnums.mqh, -100/-101), kept as local macros for the same include-order //--- independence as the SL/TP sentinels above. ENTRY_INTELLIGENT_BASE_MULT is the DEEPEST limit //--- pullback (in ATRs, at zero confidence); it shrinks linearly to 0 (market fill) as confidence -> 1. #define ENTRY_INTELLIGENT_MODE (-100) #define ENTRY_PREV_SWING_MODE (-101) #define ENTRY_INTELLIGENT_BASE_MULT 2.0 // 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). //--- Direction() snapshots the scratch into the per-side slots below around each ladder call, so a //--- short-side match can no longer overwrite what the long ladder found (and vice versa). 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(). NOTE: this is a record of the DECISION LAYER'S state at log time, not an //--- objective measure - the per-pattern weights inside it are themselves adjusted by //--- UpdateSignalsWeights(), so its scale drifts as ranking updates land. The objective part of a //--- row is the pattern/direction/price/result columns; netVote is the decision context they were //--- logged under. double m_lastNetVote; //--- The two ladder results behind m_lastNetVote, kept apart from it because the net alone cannot //--- answer "at what weight". A filter that fired Pattern_2 (weight 75) long while a short pattern //--- also matched at 75 has a net of 0 and two live ladders; the raw arrow layer needs the SIDE's //--- own weight to label itself honestly. Written by Direction() on the same tick the patterns are //--- snapshotted, so weight and pattern always describe the same evaluation. int m_lastLongWeight; int m_lastShortWeight; //--- The AI filters' own weighted-mean vote for this bar, on the same 0-100 win-rate scale as //--- m_direction. Written by Direction(), read by CheckClosePosition()'s early-exit route. //--- //--- REPLACED a softmax-confidence read (LiveSignedConfidence() against m_ai_exit_threshold = //--- Min_Vote_Close/100). That worked while the vote was an arbitrary weight, but the moment //--- Min_Vote_Close became a CONFIDENCE PERCENTAGE the one input drove two different scales: //--- a win-rate estimate on the vote route and a model-confidence magnitude on this one. That //--- is precisely the currency mismatch removed from the ensemble deploy gate in 2c443ba, and //--- re-introducing it one function away would have been the same bug wearing the same disguise. //--- LiveSignedConfidence() is untouched and still 0..1: MM sizing, SL/TP scaling and the //--- intelligent trailing all genuinely want a model confidence, not a win rate. double m_lastAiVote; //--- HISTORICAL FILTERED-OVERLAY sweep state (see AdvanceFilteredOverlay). Chunked across timer //--- slices rather than run in one pass: an unchunked full-history sweep with no yield is what //--- froze the terminal on the 2026-07-26 arrow restore, and this one calls Direction() on every //--- classic filter at every bar, which is strictly more work than that was. bool m_overlayPending; int m_overlayIndex; // next bar index to process, walking newest -> oldest 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. The two must never write the same //--- bar - a reconstruction cannot know the broker rejected an order, so it would silently //--- promote a rejected setup back into a trade the chart claims was taken. 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; 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. It now prints when the RESULT //--- moved (drawn count changed, or the strongest vote moved >=2pp) and at least every 10th //--- sweep either way, so a stuck number is still provably stuck from the file alone. 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; //--- Session peak |vote|, for the readout. The single most useful number for choosing //--- Min_Vote_Open: 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; //--- How many per-member HUD lines are currently on the chart, so a shrink (member disabled, //--- filters rebuilt) deletes the orphans instead of leaving a frozen line from a model that //--- no longer exists - the exact stale-display failure the snapshot rule exists to prevent. int m_hudMemberLines; //--- 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: >0 = fixed ATR multiple beyond swing; SL_INTELLIGENT(-1) = AI-confidence scaled int m_tp_mode; // TAKE_PROFIT_MODE int: >0 = fixed ATR multiple from entry; TP_INTELLIGENT(-1) = AI-confidence scaled int m_confidence_source; // CONFIDENCE_SOURCE underlying int (0=AI, 1=DB, 2=Blended) //--- 0..1 min. AI confidence, reversed against the position, required to trigger an early exit. Set //--- from the SAME Min_Vote_Close input that drives m_threshold_close, just rescaled - see that //--- input's declaration comment (Variables\Inputs.mqh) for why one number governs both exit routes. //--- There is deliberately no companion on/off flag: Min_Vote_Close = Disabled resolves to 1.01 here, //--- which no softmax confidence can reach, so the route switches itself off. //--- RETIRED 2026-08-18 with the move to confidence-percentage thresholds. It held //--- Min_Vote_Close/100 for a route that now tests m_lastAiVote against m_threshold_close on //--- the one 0-100 scale, so a second rescaled copy of the same input has nothing left to do. //--- Removed rather than left dangling: an unused threshold member is exactly the shape that //--- trained ~250 eras on the wrong target once already (see the stale-enum note in //--- Enumerations\InputEnums.mqh) - the next reader cannot tell a retired knob from a live one. //--- 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. Live vote-driven exits (the averaged-vote close and the AI early-exit route //--- in CheckClosePosition, plus CExpertCustom::CheckReverse) close EARLIER whenever the vote flips //--- - and a fractal-target model's vote flips at swing-marker cadence (~every 3-5 bars), so its //--- live trades were systematically cut before the certified barrier could decide (observed by the //--- user as "a sell not far from a buy and price kept rising"). When true, every vote-driven exit //--- is suppressed and the position runs to its broker SL/TP; risk guards and trailing (if enabled) //--- are deliberately untouched - they are account protection, not signal opinion. bool m_holdToBarrier; double m_dbConfidence; // last average normalized DB win-rate across active filters //--- Direction()'s per-second aggregation state. MUST be per-instance, not function-local statics - //--- Direction() is inherited as-is (not overridden) by every CExpertSignalCustom subclass that //--- doesn't provide its own (the root "signal" object AND CExpertSignalAIBase, so PAI/CONV/LSTM), //--- meaning they'd all share one compiled function body. Function-local statics there would be a //--- single instance shared across the root signal and every AI filter, each stomping on the //--- others' in-progress per-second average instead of keeping their own. //--- The window key is a full timestamp (broker clock since 2026-08-19), NOT MqlDateTime.sec. Keying on the 0-59 seconds FIELD //--- alone made two calls a minute (or an hour, or a day) apart look like the same window: with //--- Expert_EveryTick=false every call lands on a bar open, where sec is always 0, so the window //--- never rolled over and every bar's vote accumulated into one ever-growing average that decayed //--- toward 0 as the run went on. A full timestamp rolls the window over on every new second, which //--- is what "average the votes cast within one second" was always meant to mean. 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); 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 ConfidenceSource(int value) { m_confidence_source = value; } void HoldToBarrier(bool value) { m_holdToBarrier = value; } bool HoldToBarrier(void) const { return m_holdToBarrier; } int LastFiredDirection(void) { return m_lastFiredDirection; } //--- HISTORICAL EVALUATION SHIFT (meta-labeling candidate sweep). Every pattern condition in every //--- signal class anchors its reads on `int idx = StartIndex();` (verified: no hardcoded indices //--- anywhere in Signals\Signal{MA,RSI,MACD,Ichimoku}.mqh), so overriding StartIndex to return a //--- historical bar index makes the REAL, live ladder code evaluate "as of that bar" - no //--- condition mirroring, no divergence trap. Non-zero only inside CSignalMETA's corpus sweep; //--- 0 = normal live behaviour (base rule: every_tick ? 0 : 1). Name-hiding is sufficient: the //--- stock StartIndex is non-virtual, but every condition body lives in classes BELOW this one, //--- so their calls resolve here. int m_evalShift; void EvalShift(const int shift) { m_evalShift = shift; } int StartIndex(void) { return (m_evalShift > 0 ? m_evalShift : (m_every_tick ? 0 : 1)); } //--- Deep-history readiness for the sweep: the price series and each signal's own indicator //--- buffers default to a shallow depth, so reads at bar 40,000 would fail. Overridden per signal //--- class to also resize its indicator; the base handles the shared price series. virtual bool SweepPrepare(const int bars) { bool ok = true; if(CheckPointer(m_open) != POINTER_INVALID) { ok = m_open.BufferResize(bars) && ok; m_open.Refresh(-1); } if(CheckPointer(m_high) != POINTER_INVALID) { ok = m_high.BufferResize(bars) && ok; m_high.Refresh(-1); } if(CheckPointer(m_low) != POINTER_INVALID) { ok = m_low.BufferResize(bars) && ok; m_low.Refresh(-1); } if(CheckPointer(m_close) != POINTER_INVALID) { ok = m_close.BufferResize(bars) && ok; m_close.Refresh(-1); } return ok; } // 0.0 = no AI confidence available (pure rule-based); overridden in // CExpertSignalAIBase to return the live signal's confidence in [0,1]. virtual double AIConfidence(void) { return 0.0; } // Signed version of AIConfidence: sign gives direction (+ buy, - sell), used for // AI-driven early exit. 0.0 = no AI filter (base rule-based class never exits early). 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). This is what lets the non-AI aggregate/root // signal - the object CExpert actually calls to size, scale, and manage every trade - see REAL AI // confidence instead of the constant 0 its own SignedAIConfidence() returns. Without it, // Intelligent MM, AI SL/TP scaling, and AI-exit were all running with their AI component pinned to 0. double LiveSignedConfidence(void); // Combines AIConfidence()/DBConfidence() per m_confidence_source into a single 0..1 // magnitude, used to scale SL/TP and (Intelligent MM) lot size. double EffectiveConfidence(void); double DBConfidence(void) { return m_dbConfidence; } 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. The raw-arrow layer needs //--- this to know which filters draw themselves (the AI members already do, from their own cached //--- per-bar scans) and which the aggregate must draw on their behalf (the classic ladders, which //--- only ever evaluate the current bar). virtual bool IsAIFilter(void) const { return false; } //--- META-LABELING GATE SEAM (S3, 2026-08-19). Overridden only by CSignalMETA; the base is a //--- no-op so a chart without a meta head pays nothing. Returns 2 = scored and approved, //--- 1 = armed but this bar was unscorable (fail-open: window/width/forward unavailable - never //--- a silent block), 0 = not armed (no meta head, or its training has not completed), //--- -1 = scored and VETOED (predicted win probability below the cost-adjusted break-even). //--- barIdx 1 is the live entry query (newest closed bar - the exact window the direction //--- models' live votes use); the ensemble verdict passes historical OOS indices to replay the //--- identical veto it certifies. No default argument on purpose: every caller states its bar. virtual int LiveMetaGate(const bool isLong, const double netVote, double &pWin, double &bePct, const int barIdx) { pWin = -1.0; bePct = -1.0; return 0; } //--- Does this filter derive its own pattern weights, making the signal DB's ranking //--- inapplicable to it? False for the classic ladders, whose patterns are fixed geometric //--- conditions and whose win rates are therefore legitimately accumulated across years. //--- True for a neural net that has measured its tiers on held-out bars - its "Pattern_2" //--- means "confidence landed in tier 2", which is a statement about weights that change //--- every era, so accumulated rows describe models that no longer exist. 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. Non-zero for any filter that COULD //--- cast a directional vote right now: the classic pattern ladders always can; the veto filters //--- (pattern count 0) never can and must not dilute a vote they can never join; an AI member //--- can once deployed (override). This is what makes abstention meaningful: a capable filter //--- that stays Neutral pulls the consensus DOWN, a filter that cannot vote at all leaves it //--- untouched. virtual double VoteCapableWeight(void) { return (GetPatternCount() > 0) ? m_weight : 0.0; } //--- 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; } //--- One HUD line describing this member's CURRENT raw opinion - the output neurons, the //--- decision they resolve to, its weighted vote, era and training error. The reference //--- library (References\MQL5\...\NeuroNet_DNG) kept exactly this on its training chart as a //--- Comment(); here it is one label per ensemble member (user request 2026-08-19: "I want //--- something similar to know what the neurons are saying and what the vote is"). Empty //--- string = no line; only AI members override. virtual string DisplayHudLine(void) { return ""; } //--- 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. Display only; nothing downstream of a trading decision may read //--- it. Returns false for a filter that has no current decision at all. 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. The historical //--- overlay replays the classic ladders by calling Direction() at hundreds of past bars, and the //--- live journaling reads m_active_pattern_long/short from the PREVIOUS Direction() call - so //--- without this the next live bar would journal whichever bar the sweep happened to stop on, at //--- the current timestamp. That is a corrupted row in the very table the pattern win rates (and now //--- the vote weights) are computed from. CSignalMETA's corpus sweep gets away without it because it //--- runs once, at the first era, before any of this state matters. 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. bool AdvanceFilteredOverlay(const int barBudget); void StartFilteredOverlay(void); bool FilteredOverlayPending(void) const { return m_overlayPending; } //--- One-line on-chart readout of the vote that is actually being tested against Min_Vote_Open. 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. Member-scoped for the same reason the AI members' is (see //--- CExpertSignalAIBase::ArrowPrefix): several filters draw on one chart and a bare prefix would //--- make them collide on the bar-time key, so the last writer would win and the chart would show //--- one filter's opinion under another's name. 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. Weight is the pattern's CURRENT weight, which under //--- UseDatabaseRanking is its measured win rate - worth showing, because "MA voted here" and "MA //--- voted here at weight 12 because its last 400 trades won 12%" are very different statements. 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 triple-barrier label assumes (see TripleBarrierLabel). 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) ObjectDelete(0, 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. //--- //--- The tooltip carries the numbers that make the mark auditable after the fact: which side, the //--- net vote that cleared, the threshold it cleared, and the stop/target the order would have //--- carried. Without the levels this is just a dot; with them it can be checked against what the //--- deploy gate certified (see the g_DerivedSlAtrMult comment in ConfidenceBridge.mqh - the EA //--- has been caught once already trading a geometry the certificate said nothing about). 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) ObjectDelete(0, 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. Added for the raw-arrow layer, which reads //--- the slots immediately after Direction() has refreshed them. 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. //--- The weighted-mean normalization in Direction() needs each child's weight as the divisor term, //--- and a parent cannot reach a child's protected member. 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; }; virtual double Direction(void) override; //--- Vote lifecycle hooks, for filters whose LongCondition()/ShortCondition() consume one-shot state //--- when they fire. No filter does today - the AI signals' alternation gate was the only user and was //--- removed with the triple-barrier relabel (see CExpertSignalAIBase) - so both hooks are currently //--- inert. Kept because the rollback contract below is the non-obvious part and is easy to get wrong //--- if a future one-shot vote is added without it. Direction() //--- calls BeginVote() on itself before polling its own conditions, and RevokeVote() on any CHILD whose //--- vote it then throws away. Without this, a vote that Hybrid's quorum suppressed still burned the //--- child's gate: PAI flipping Buy alone on bar 10 consumed its Buy gate, so when CONV flipped Buy on //--- bar 12 PAI was already gated to 0 and the count was STILL 1 of the 2 required - in practice all //--- three models had to flip on the very same bar, and every near-miss cost a model that direction //--- until the opposite signal arrived. Deliberately NOT revoked on the prohibition path: a vetoed tick //--- still blocks only OPENING (see CheckOpenPosition), and the vote does reach m_direction where //--- CheckClosePosition can act on it, so that vote was used, not discarded. 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_lastAiVote(0.0), m_overlayPending(false), m_overlayIndex(0), m_overlayStopIndex(0), m_overlayLiveCutoff(0), m_overlaySweptBars(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_votePeak(0.0), m_hudMemberLines(0), m_lastLiveVoters(0), m_evalShift(0), 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_confidence_source(0), m_holdToBarrier(false), m_dbConfidence(0.0), m_directionCurrentSecond(0), m_directionAggregatedResult(0.0), m_directionCount(0), m_directionLastResult(0.0), m_lastFiredDirection(0) { } //+------------------------------------------------------------------+ //| Combine AI/DB confidence per the configured Confidence_Source | //+------------------------------------------------------------------+ double CExpertSignalCustom::LiveSignedConfidence(void) { double own = SignedAIConfidence(); if(own != 0.0) return own; //--- THE ORCHESTRATOR COMBINES; the members only publish. On an ensemble chart this is the mean of the //--- four members' live votes rather than whichever one wrote the shared global last - see the vote //--- board in Variables\ConfidenceBridge.mqh. Republished into g_LiveAISignedConfidence because the //--- intelligent trailing reads that global directly and must see the same aggregate this exit route //--- acts on, not a leftover from a member's own per-tick write. g_LiveAISignedConfidence = AggregateAIVotes(); return g_LiveAISignedConfidence; } double CExpertSignalCustom::EffectiveConfidence(void) { g_AISignedConfidence = LiveSignedConfidence(); g_DBConfidence = m_dbConfidence; return CombinedConfidence(m_confidence_source); } //+------------------------------------------------------------------+ //| 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; int total = m_filters.Total(); //--- gather information about using of timeseries for(int i = 0; i < total; i++) { filter = m_filters.At(i); 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 = m_filters.At(i); 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. Reproducing that decision here (rather than assuming | //| "Entry_Multiplier != MARKET means pending") is what lets | //| OpenParams() validate the SL/TP against the right reference | //| price - the article measures a market order's stops from the | //| OPPOSITE side of the spread and a pending order's from its own | //| activation price, and those are different numbers. | //+------------------------------------------------------------------+ ENUM_ORDER_TYPE CExpertSignalCustom::ResolveOrderType(bool isLong, double price) { if(price <= 0.0) return(isLong ? ORDER_TYPE_BUY : ORDER_TYPE_SELL); double stops = TCStopsLevel(m_symbol.Name()); if(isLong) { double ask = m_symbol.Ask(); if(price > ask + stops) return(ORDER_TYPE_BUY_STOP); if(price < ask - stops) return(ORDER_TYPE_BUY_LIMIT); return(ORDER_TYPE_BUY); } double bid = m_symbol.Bid(); if(price > bid + stops) return(ORDER_TYPE_SELL_LIMIT); if(price < bid - stops) return(ORDER_TYPE_SELL_STOP); return(ORDER_TYPE_SELL); } //+------------------------------------------------------------------+ //| 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. The validity // guards below therefore reject the setup only when it genuinely depends on a swing: previously an // unsynced or thin history rejected EVERY trade, including configurations whose levels no longer // reference a swing at all. 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; } // Refresh the confidence bridge every tick regardless of SL/TP mode, so Intelligent MM // (Money\MoneyIntelligent.mqh), the intelligent trailing (Trailing\TrailingIntelligent.mqh), and // intelligent entry below all see a fresh value even when SL/TP are left on fixed-ATR presets. double confidence = EffectiveConfidence(); if(!MathIsValidNumber(confidence)) confidence = 0.0; // --- Entry price. Offsets are measured from the CURRENT price (base_price = bid/ask), except // ENTRY_PREV_SWING which anchors to the recent swing. The resulting price is what // CExpertTrade::Buy/Sell routes into a market / limit / stop order (it compares price to // ask/bid +- the broker stop-level itself), so a near-market price simply fills at market. int entryMode = (int)m_entry_multiplier; if(entryMode == ENTRY_PREV_SWING_MODE) price = m_symbol.NormalizePrice(isLong ? lowest_low : highest_high); else if(entryMode == ENTRY_INTELLIGENT_MODE) { // Deep limit pullback when unsure, shrinking to a market fill as confidence -> 1. double pull = ENTRY_INTELLIGENT_BASE_MULT * (1.0 - confidence) * atr; price = m_symbol.NormalizePrice(isLong ? (base_price - pull) : (base_price + pull)); } 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. SL_ATR_* use that multiple directly; SL_INTELLIGENT starts at // SL_INTELLIGENT_BASE_MULT and tightens as confidence rises. // Anchored to `price`, NOT to base_price: with a pending entry (Entry_Multiplier / ENTRY_*), // `price` is where the trade will actually fill, and the risk that Money sizes against is // entry-to-stop. Measuring from the current bid/ask instead would make the realised risk differ // from the configured multiple by the whole entry offset. //--- MEASURED GEOMETRY OVERRIDE (2026-08-09). When the AI signal has derived (or adopted from its //--- .cfg) the barrier geometry its labels are built on, the LIVE trade uses that exact pair - both //--- legs, all modes, including the Intelligent ones. Not optional and not blended with confidence, //--- because the deploy gate's certificate is precise: "reaches g_DerivedTpAtrMult*ATR before //--- g_DerivedSlAtrMult*ATR at a win rate above break-even". A trade with any other geometry is a //--- different bet, one the gate never graded - the model was being graded on one game and paid on //--- another. Both-or-neither, same guard as every other consumer of a derived pair. bool useDerivedGeometry = (g_DerivedSlAtrMult > 0.0 && g_DerivedTpAtrMult > 0.0); double slMultiplier; if(useDerivedGeometry) slMultiplier = g_DerivedSlAtrMult; else if(m_sl_mode == SL_INTELLIGENT_MODE) slMultiplier = SL_INTELLIGENT_BASE_MULT * (1.0 - AI_SL_TIGHTEN_FACTOR * confidence); else 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). Deliberately // applied BEFORE take profit below: TP_INTELLIGENT sizes itself off the FINAL entry-to-stop distance, // so a floor that widened the stop afterwards would silently shrink the realised reward:risk below the // ratio that mode is meant to guarantee - and, at the shipped defaults, straight back under the Min RR // rejection threshold. if(fabs(price - sl) < (MIN_SL_ATR_MULTIPLIER * atr)) sl = isLong ? (price - MIN_SL_ATR_MULTIPLIER * atr) : (price + MIN_SL_ATR_MULTIPLIER * atr); double risk = fabs(price - sl); // --- Take profit: TP_ATR_* are an ATR multiple FROM THE ENTRY PRICE; TP_INTELLIGENT is a multiple of // THIS TRADE'S OWN RISK, widening with confidence. Min RR (below) only rejects, never reshapes // either. Now that the stop is entry-anchored, risk IS exactly slMultiplier*ATR, so the // risk-relative and ATR-relative formulations coincide - TP_INTELLIGENT stays risk-relative // because that keeps its reward:risk guarantee exact even after the MIN_SL_ATR_MULTIPLIER floor // or TCAdjustStops() widens the stop (see TP_INTELLIGENT_BASE_RR's comment). if(useDerivedGeometry) { //--- ATR-anchored like the label, NOT risk-relative: the label measures "reach tp before sl" as //--- two independent ATR distances from the entry, so the live target must be the same distance - //--- tying it to the (possibly floor-widened) realised risk would silently reshape the certified //--- geometry on exactly the trades whose stop got adjusted. tp = isLong ? m_symbol.NormalizePrice(price + g_DerivedTpAtrMult * atr) : m_symbol.NormalizePrice(price - g_DerivedTpAtrMult * atr); } else if(m_tp_mode == TP_INTELLIGENT_MODE) { double targetRR = TP_INTELLIGENT_BASE_RR * (1.0 + AI_TP_WIDEN_FACTOR * confidence); tp = isLong ? m_symbol.NormalizePrice(price + targetRR * risk) : m_symbol.NormalizePrice(price - targetRR * risk); } else { 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. Nothing upstream enforced this: SL is anchored to a // recent swing and TP to an ATR/RR multiple, both of which can land inside the broker's minimum // distance on a quiet bar or a wide-spread symbol - the trade was then built, sized by Money, // and rejected server-side with "Invalid stops" (10016) with nothing in the log explaining why. // Which order type this becomes is decided by CExpertTrade::Buy()/Sell() purely from `price` vs // ask/bid +- the stops level, so the same comparison is reproduced here to pick the type the // stops will actually be validated against. 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); // A widened stop changes this trade's real risk, so recompute it before the reward:risk filter // below - otherwise the RR the trade is accepted on is not the RR it is actually taken at. risk = fabs(price - sl); // 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 MEASURED AND PUBLISHED, NOT ENFORCED (2026-08-09). The minimum-ratio rejection that // stood here is gone with the Min_Risk_Reward_Ratio input - see Variables\Inputs.mqh. It could only // ever veto a setup whose SL/TP the pipeline had already chosen, and vetoing on a ratio does not // improve expectancy: it trades hit rate against payoff at a break-even the geometry already fixes. // What it did do was reject 100% of setups on every symbol once, which is four Market validation // failures for "no trading operations". Account risk % and CRiskBudget's drawdown enforcement are // what bound risk here. double reward = fabs(tp - price); // Still computed and still bridged to Money\MoneyIntelligent.mqh's Kelly-criterion sizing - the // ratio remains a genuine INPUT to how big the position should be, which is the use that was // always sound. Only the veto is gone. g_TradeRewardRiskRatio = (risk > 0.0) ? reward / risk : 0.0; // 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; //--- ONE EXIT AUTHORITY, tied to whichever engine's certificate the trade was placed under. //--- //--- g_DerivedSlAtrMult > 0 means an AI model's MEASURED geometry is on this order (OpenParams), which //--- means the deploy gate's certificate is the reason the trade exists: "reaches TP*ATR before SL*ATR //--- at a win rate above break-even". That certificate is measured on hold-to-resolution outcomes, and //--- CExpertSignalAIBase's exit replay reproduces exactly ONE exit rule - the AI early-exit route below, //--- which reads the AI vote undiluted. The blended route here cannot be reproduced by that replay at //--- all: m_direction is the average over EVERY filter, including classic ones whose live votes pass 3 //--- never computes. Leaving it armed means the EA can close on a signal the certificate never modelled, //--- which is the same failure as the 2026-08-09 geometry mismatch - graded on one game, paid on another. //--- //--- So when the AI's geometry governs the order, the AI governs the exit. When it does not (classic-only //--- configuration, or before any model has derived a pair), this route is the only exit opinion there is //--- and it stays exactly as it was. Nothing changes at the shipped defaults either way: Min_Vote_Close //--- ships Disabled, so m_threshold_close is 101 and neither route can fire. bool aiCertificateGoverns = (g_DerivedSlAtrMult > 0.0 && g_DerivedTpAtrMult > 0.0); // Allowing position closing without checking the prohibition signal. if(!aiCertificateGoverns && directionMultiplier * m_direction >= m_threshold_close) result = true; // AI-driven early exit: close regardless of the rule-based threshold above if the AI side of the vote // has flipped against the open position and reaches m_threshold_close on its own. m_lastAiVote is // built by Direction() from the AI filters only, so this is a no-op when no AI signal is active or // converged (it stays 0.0), and when Min_Vote_Close is Disabled m_threshold_close is 101 - which a // weighted mean of 0-100 pattern weights cannot reach, so the route switches itself off by // arithmetic exactly as it always did. // // This is NOT redundant with the averaged vote above, which is why it exists as a second route rather // than being folded into it. The AI's ordinary vote is AVERAGED with every other filter's, so an AI // reversal landing on a bar where that average stays under m_threshold_close is diluted away and the // position stays open for as long as the dilution lasts. Reading the LIVE signed confidence here, // undiluted and every bar, is what closes that hole. (This used to be a sharper problem: the vote was // also one-shot, because the alternation gate was consumed on firing and never re-offered. That gate is // gone as of 2026-08-01, so the remaining gap is dilution alone - still real, still worth this route.) if(!result) { //--- ONE SCALE. This used to read LiveSignedConfidence() (a 0..1 softmax magnitude) against //--- m_ai_exit_threshold (Min_Vote_Close/100). Now that Min_Vote_Close is a confidence //--- PERCENTAGE, both exit routes must be asking the same question of the same quantity, so this //--- reads the AI filters' own weighted-mean vote - undiluted by the classic side, which is the //--- only reason this route exists - against the same m_threshold_close the averaged vote above //--- is tested with. Disabled (101) stays unreachable here exactly as it was: the vote is a //--- weighted mean of pattern weights and cannot exceed 100. double aiVote = m_lastAiVote; bool reversedAgainstLong = isLong && aiVote < 0.0 && MathAbs(aiVote) >= m_threshold_close; bool reversedAgainstShort = !isLong && aiVote > 0.0 && MathAbs(aiVote) >= m_threshold_close; if(reversedAgainstLong || reversedAgainstShort) 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). The symbol's session table is the authority on whether an //--- order can exist right now: without this, boundary bars (the Sunday reopen, an index CFD's //--- daily maintenance break) let a vote fire into a closed book and collect a broker error //--- instead of a decision. 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) { //--- there's a signal result = true; //--- META-LABELING GATE (2026-08-19, user design: the meta head integrated into the voting //--- decision pipeline). Runs ONLY on a vote-cleared entry - one MLP forward per proposed //--- trade - and only ever vetoes: the fail-open codes (0/1/2) let the trade proceed //--- untouched. Entries only; exits, SL/TP and the scheduled close-all never consult it //--- (closing risk must never be blocked). No vote-state revoke on a veto: the child votes //--- were real and the policy declined the trade - same shape as the market-hours gate //--- above, unlike the OpenParams failure below which restores votes for retry. if(g_warriorMetaGate != NULL) { double mgP = -1.0, mgBe = -1.0; if(g_warriorMetaGate.LiveMetaGate(isLong, m_direction, mgP, mgBe, 1) < 0) { if(ShouldTraceTradeRejections()) TraceSignalRejection("open-meta-veto", StringFormat("%s: open %s rejected by the meta gate - P(win) %.1f%% below the" " cost-adjusted break-even %.1f%% (vote %.1f).", __FUNCTION__, isLong ? "long" : "short", 100.0 * mgP, mgBe, m_direction)); return false; } } //--- 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 Min_Vote_Open 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. An arrow drawn at the //--- threshold would claim trades the EA never places, which is the same overstatement the live //--- NMS fix removed from the AI arrows (one arrow per EIGHT positions, in the other direction). //--- So the arrow is placed only after the order parameters validate, below, and any arrow //--- already standing on this bar is withdrawn here. 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 (INTELLIGENT resolves to the // measured drift verdict - see WarriorEffectiveDirection) 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 - %s blocks long entries.", __FUNCTION__, (tradingdirection == DIRECTION_INTELLIGENT) ? "the measured drift verdict (Intelligent)" : "strategy direction")); 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 (INTELLIGENT resolves to the // measured drift verdict - see WarriorEffectiveDirection) 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 - %s blocks short entries.", __FUNCTION__, (tradingdirection == DIRECTION_INTELLIGENT) ? "the measured drift verdict (Intelligent)" : "strategy direction")); 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; } //+------------------------------------------------------------------+ //| 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. It was TimeGMT() - //--- while the online-learning backfill stamped rows with BAR time (server) - so the SAME //--- database mixed two time bases ~3h apart, and the newest-row duplicate guard compared them //--- on one axis: a live row landing within the offset after a backfill row was silently //--- rejected as outdated. 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. It used to //--- close the previous window here and return that value, which meant the value handed to //--- CExpert(Custom)::SetDirection() -> m_direction (the field CheckOpenPosition/CheckClosePosition //--- actually threshold against) was always the PREVIOUS second's average, never this call's own //--- vote. With Expert_EveryTick=false, Direction() runs exactly once per bar at the bar open, so //--- the clock's .sec is 0 on every single call: after the very first call the branch below never fired //--- again, m_directionLastResult stayed pinned at its 0.0 seed forever, and m_direction was 0 on //--- every bar - no signal could ever reach m_threshold_open and the EA could not open a single //--- trade, in Classic, AI-only or Hybrid alike (they all inherit this one Direction() body). It also //--- silently ate the AI vote entirely: at the time, CExpertSignalAIBase::LongCondition/ShortCondition //--- consumed a one-shot alternation gate when they fired, so the discarded vote was never re-offered on //--- a later bar (that gate was removed 2026-08-01; the ordering bug it amplified was real either way). //--- The window average is now computed at the end of this function //--- with this call's own result folded in, so what is returned always includes the current tick. 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); int number = (result == 0.0) ? 0 : 1; //--- The weighted mean's DIVISOR, seeded with this signal's own module weight on exactly the same //--- condition `number` is seeded - an abstention contributes to neither sum. On the aggregate/root //--- signal this seed is always 0: the root has no patterns of its own, so its long/short conditions //--- return 0 and `result` starts at 0. It matters for a filter that has children of its own. double weightSum = (result == 0.0) ? 0.0 : m_weight; //--- AI-only numerator/divisor pair, filled in pass 2 - see m_lastAiVote. double aiResult = 0.0, aiWeightSum = 0.0; 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. Quorum suppression //--- (pass 2) needs every quorum-flagged filter's m_lastFiredDirection already fresh for THIS tick; //--- checking mid-loop, as a single pass used to, would compare against filters not yet visited this //--- iteration (stale, still holding last tick's value). 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; } string filterID = filter.GetFilterID(); //--- Per-side pattern journaling: each ladder that MATCHED on this filter's last evaluation //--- writes its own row, labelled by its own side, with the filter's net vote stored as data //--- (netVote column) rather than used as a drop filter. The previous design kept ONE //--- last-writer-wins label across LongCondition() then ShortCondition() and only journaled it //--- when it agreed with the net vote's sign. That gate was added to stop flat-vote bars from //--- writing directional rows, but it censored structurally: a long event co-occurring with any //--- short-side STATE model lost its label to the later writer and was dropped (vote positive, //--- label "Sell"), while the mirrored short event journaled fine because the long ladder wrote //--- first. Ichimoku models 0/3 and MA model 1 could not produce a row AT ALL by construction, //--- and every pattern's recorded win rate was measured on a with-trend-only subset - the exact //--- statistic UpdateSignalsWeights() feeds back into that pattern's weight, and a self-sealing //--- loop: no rows -> no win rate -> default weight -> still censored. Per-side labels keep the //--- flat-vote bug fixed without the censoring: a ladder that matched nothing has "NULL" and //--- writes nothing, and a label can no longer contradict the side it is filed under. Like the //--- single label before them, both slots (and LastNetVote()) are written by this filter's OWN //--- Direction() and read here one tick later, so pattern and netVote describe the same tick. //--- The log is unconditional on the DECISION layer: no OpenLongParams()/OpenShortParams() gate //--- here any more. Those calls validate order placement (broker stops-level, ATR warm-up, //--- entry-mode rejection), and their failures cluster in volatility/spread conditions - gating //--- the log on them non-randomly censored exactly those bars out of every pattern's win-rate //--- sample. The ledger doesn't need placement to be possible: its entries are marked at the //--- touchable side of the spread below, and its exits are same-pattern reversals, not broker //--- fills. Whether a tradable order could have been built from the signal is the decision //--- layer's question, answered downstream from weights this log exists to inform. string patternLong = filter.GetActivePatternLong(); string patternShort = filter.GetActivePatternShort(); if(filterID != "NULL" && m_useDatabase) { double filterNetVote = filter.LastNetVote(); if(patternLong != "NULL") BufferNewTickSignal(filterID, patternLong, "Buy", brokerTime, m_symbol.Ask(), filterNetVote); if(patternShort != "NULL") BufferNewTickSignal(filterID, patternShort, "Sell", brokerTime, m_symbol.Bid(), filterNetVote); } double direction = filter.Direction(); //--- RAW VIEW, classic filters only, and it must sit AFTER the Direction() call above rather //--- than beside the journaling block. The AI members draw their own arrows from their own //--- cached per-bar scans (which span the whole chart, not just this bar), so drawing them //--- again from here would double up. The classic ladders have no such scan - they only ever //--- answer for the bar in front of them - so this is the ONLY place their opinion is visible. //--- //--- THE BAR IS THE POINT. patternLong/patternShort read above are CONSUMING reads filled by //--- this filter's PREVIOUS Direction() call - "one tick later", as the journaling comment puts //--- it, which with Expert_EveryTick=false means one BAR later. Keying an arrow off them while //--- placing it at StartIndex() would draw the previous bar's pattern on the current bar, and a //--- one-bar-late arrow is indistinguishable on a chart from a model that is genuinely early. //--- Peeking (non-consuming) after the fresh Direction() call means pattern, weight and bar all //--- come from the same evaluation, with nothing to reason about. if(DrawUnfilteredSignals && !filter.IsAIFilter()) { 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); } 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; number = 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. //--- //--- CONSENSUS, NOT UNION, since 2026-08-19 - the denominator is every CAPABLE filter's weight, //--- whether or not it voted this bar. Under the old voters-only divisor the vote's magnitude on //--- any voted bar was simply the weighted mean of the firing tiers' weights - and once the tiers //--- self-ranked to a model's pooled win rate (~28-31 measured), that mean was NEAR-CONSTANT //--- regardless of how many members agreed: one member alone read ~29, four unanimous members //--- read ~29. Min_Vote_Open degenerated into a step function around that constant - at 30 the //--- chart drew nothing, at 20 it drew on every voted bar, both observed on 2026-08-18/19 and //--- neither usable. Dividing by the capable weight makes agreement the thing the number //--- measures: full agreement reads the pooled win rate (the CAP), one-of-four reads a quarter of //--- it, a 3v1 split nets down. This is the ensemble design the user specified originally ("if //--- the perceptron also votes... both together reach the threshold; if another NN votes the //--- other side the threshold is not reached") - union semantics was the pre-ensemble behaviour //--- it replaces. 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. double capW = filter.VoteCapableWeight(); weightSum += capW; if(filter.IsAIFilter()) aiWeightSum += capW; if(direction == 0) continue; number++; // voters only - the display's "N voter(s)" and the fired/abstained distinction long mask = ((long)1) << i; double signedDir = ((m_invert & mask) != 0) ? -direction : direction; result += signedDir; //--- AI-ONLY sub-vote for the early-exit route in CheckClosePosition() - same consensus //--- arithmetic over the AI members alone, so an AI-side reversal is measured against the //--- AI side's own capable weight. if(filter.IsAIFilter()) aiResult += signedDir; } } //--- Publish the AI sub-vote on the SAME 0-100 win-rate scale as m_direction, so the close //--- threshold means the identical thing on both exit routes. m_lastAiVote = (!aborted && aiWeightSum > 0.0) ? (aiResult / aiWeightSum) : 0.0; //--- NORMALIZATION - the divisor is the CAPABLE weight (see pass 2), so the result reads as //--- "win-rate estimate x fraction of the ensemble's trust that agrees, net". Full agreement reads //--- the weighted mean win rate of the firing patterns (that is the vote's CEILING - the census/ //--- readout peak shows it, and Min_Vote_Open MUST sit below it to ever fire); partial agreement //--- and splits read proportionally less. Still a confidence percentage at full consensus (user //--- request 2026-08-18), now with agreement as the thing the threshold actually dials. //--- //--- Each filter contributes m_weight x patternWeight, and under UseDatabaseRanking BOTH of those are //--- win rates: patternWeight is that pattern's measured win rate (UpdateSignalsWeights -> //--- ApplyPatternWeight) and m_weight is the filter's average win rate over its patterns, /100. So //--- dividing by the COUNT produced a mean of PRODUCTS of two win rates - a genuinely 60%-accurate //--- filter firing a 60% pattern scored 0.60 x 60 = 36, not 60. That is the same quadratic derating //--- the m_pattern_0 comment describes for the single-pattern case, and it is why a threshold of 20 //--- was ever a sensible default: the number was never on a probability scale at all, so its //--- magnitude meant nothing on its own. //--- //--- Dividing by Sum(m_weight) instead makes this a WEIGHTED MEAN of win rates, which IS a win rate: //--- result = Sum(w_i * p_i) / Sum(w_i) //--- Every voter at 60% now reads 60 regardless of module weights; MACD's double-divergence pattern //--- (weight 100) voting alone reads 100. m_weight stops being a discount on the probability and //--- becomes what it should always have been - how much this filter's opinion COUNTS toward the //--- average, not how much its estimate is marked down. //--- //--- (2026-08-19: the paragraph that stood here defended union semantics - abstentions out of both //--- sums, a lone voter normalizing to its own number. Measured against self-ranked weights that //--- design produced a near-constant vote and a step-function threshold; see pass 2's comment for //--- the numbers. Consensus replaced it.) //--- //--- CALIBRATION CAVEAT, stated here because this is where the claim is made: the result is only a //--- real probability to the extent the pattern weights are. A pattern with fewer than //--- MIN_TRADES_FOR_WIN_RATE journaled trades keeps its DEFAULT weight, which is a designed prior //--- (25/50/75/100 for the AI tiers, the classic ladders' own conviction scale) and not a measurement. //--- Until the signal DB fills, "60" means "the designed conviction of the patterns that fired", not //--- "60% of these won". //--- ...AND ONLY AN AGGREGATE NORMALIZES. `total > 0` is load-bearing, not a micro-optimisation. //--- //--- Direction() is INHERITED AS-IS by every CExpertSignalCustom subclass (see m_directionCurrentSecond's //--- comment) - the root aggregate and every leaf filter run this same function body. A leaf has no //--- child filters, so its numerator is exactly `m_weight * ownNet` and its weightSum is exactly //--- `m_weight`: dividing there hands the parent `ownNet` with the module weight DIVIDED STRAIGHT BACK //--- OUT. The parent then computes Sum(p_i)/Sum(w_i) instead of Sum(w_i*p_i)/Sum(w_i) - inflated by //--- 1/mean(w). //--- //--- Which is exactly the failure reported on 2026-08-18, "nothing on the charts": at m_weight == 1 the //--- two forms agree, so a fresh AI signal looked correct. The moment RankTiersFromOos() set //--- Weight(pooled/100) - or UpdateSignalsWeights() moved a classic filter's weight off 1.0 - a vote of //--- 60 became 60/0.4 = 150, the +-100 range check below zeroed it, and with the raw arrow layer switched //--- off by DrawUnfilteredSignals the chart had nothing left to show at all. The tell in the log is //--- "Directional result is out of range. Setting to 0." on every bar. //--- //--- A leaf must therefore return its WEIGHTED contribution (w*p), because that is what the parent's //--- Sum(w_i) divisor is the matching denominator for. Only a signal that actually aggregates - which in //--- this EA is only ever the root, since AddFilter() is called on nothing else - divides. if(!aborted && total > 0 && weightSum > 0.0) result /= weightSum; //--- 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. Guarded on `total > 0` for the same reason the normalization above is: //--- Direction() is inherited as-is by every leaf filter, so without it each filter would draw its //--- own opinion into the one shared label and the last one to run would win - the reader would be //--- looking at an arbitrary member's number believing it was the vote. 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. LongCondition()/ShortCondition() return 0 behind the readiness gate for //--- the entire training run, so the live vote is structurally 0 for hours and the readout said //--- "0.0%, 0 voters" the whole time. That is honest and completely useless: it is the same //--- display whether the models are silent, undeployed, or the filter list is empty. //--- //--- So when there is no real vote, RefreshVoteReadout() below falls through to the PROSPECTIVE //--- one. m_lastLiveVoters is the latch it keys on: a real vote (number > 0) is displayed as-is //--- and stays authoritative until the NEXT Direction() call replaces it; only a bar with no //--- live voter hands the label to the prospective view. 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. // The original design fetched BOTH full tables into MQL struct arrays per signal, which is the // real constraint the historical 1000-row cap protected against: SQLite has no row limit, but // materializing thousands of string-bearing structs per signal event does not scale, and an // 18-year corpus build would have crawled. 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). It used to set a flag // that skipped registration, which one-sided the ledger for every pure EVENT pattern: signals like // MACD model 3 (zero-line cross) strictly alternate Buy/Sell, so each reversal was consumed as an // exit and every row landed on whichever side fired first (measured: 60 Buy rows, 0 Sell rows over 7 // months). The side that never registered also never got a win rate, so UpdateSignalsWeights() // weighted the pattern from one side only. State patterns escaped only by re-firing one bar later. 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. (This is also why a corpus-building backtest must // start from an empty DB - see the warning in Expert\AIBase\MetaCorpus.mqh.) 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. | //| | //| Every other number on this chart is downstream of one quantity - | //| the weighted mean the open threshold is compared against - and | //| until now that quantity was the only thing never displayed. A | //| chart with no arrows could mean the models abstained, the vote | //| was diluted, or the threshold is unreachable, and telling those | //| apart meant waiting for an era to end and reading the gate line. | //| | //| PEAK IS THE POINT, more than the current value. Min_Vote_Open is | //| unreachable if it sits above what the vote ever attains, and that | //| is not knowable from a single bar - it is exactly the "unreachable | //| gate vs merely unmet gate" confusion this project has already paid | //| for twice. Peak makes it a glance instead of an investigation. | //| | //| CORNER_RIGHT_UPPER deliberately: the status lines, the control | //| panel and the ensemble panel all live on the left, and a readout | //| that overlaps them is one the user turns off. | //+------------------------------------------------------------------+ void CExpertSignalCustom::UpdateVoteReadout(const double vote, const int voters, const int neutrals, const bool prospective) { double mag = MathAbs(vote); if(MathIsValidNumber(mag) && mag > m_votePeak) m_votePeak = mag; //--- The peak SHOWN is the larger of the live peak and the overlay census's strongest vote. The //--- census number is the one that answers the threshold question - the strongest vote across //--- ~5,000 reconstructed bars under the CURRENT weights - and both reset together at the same //--- regime boundary (StartFilteredOverlay), so they are always in the same money. double peak = MathMax(m_votePeak, m_overlayBestNet); //--- A PROSPECTIVE vote can never be a trade, however high it reads - the models are not deployed. //--- Saying "-> TRADE" on a number that cannot place an order would be the exact overstatement //--- this readout exists to prevent. //--- A vote on a side the direction policy blocks (LONG_ONLY/SHORT_ONLY, or the Intelligent //--- drift verdict) cannot place an order, so it must not read "-> TRADE" - the exact //--- overstatement this readout exists to prevent. bool fires = (mag >= m_threshold_open) && (voters > 0) && !prospective && (vote == 0.0 || WarriorDirectionAllows(vote > 0.0)); //--- THE HEADLINE WORD IS THE DECISION, NOT THE LEAN (user request 2026-08-19). It used to name //--- the sign of any nonzero net, so one member voting BUY at weight 7 against three flats read //--- "VOTE BUY 5.9%" all day - an ensemble that looked permanently long while it would trade //--- nothing. With the per-member lines now showing every model's individual leaning, the top //--- line says what the bot would DO: BUY/SELL only at or above Min_Vote_Open, NEUTRAL below //--- it (including the all-flat read - the models answered, and the answer is no trade), and //--- "--" only when nobody has a decision at all. The lean itself survives in the SIGNED //--- percentage after the word (+ = buy side, - = sell side), so nothing is hidden - it is //--- just no longer wearing the word. bool clears = (voters > 0) && (vote != 0.0) && (mag >= m_threshold_open); string dir = (voters <= 0 && neutrals <= 0) ? "--" : (clears ? (vote > 0.0 ? "BUY" : "SELL") : "NEUTRAL"); //--- Consolas so the columns line up as the numbers change width - a readout that jitters is one //--- you have to re-read every time instead of glancing at. string verdict = prospective ? "-> 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); string txt = StringFormat("VOTE %s %+5.1f%% peak %5.1f%% need %.0f%% %s %s", dir, vote, peak, m_threshold_open, who, verdict); string nm = VOTE_HUD_PREFIX; if(ObjectFind(0, nm) < 0) { //--- ObjectFind is affordable HERE, unlike in the arrow paths: this is ONE object refreshed once //--- per bar, not thousands created in a sweep. The O(n^2) rule that bans the pre-check there is //--- about per-object cost in a loop, and applying it blindly here would just leak properties. ObjectCreate(0, nm, OBJ_LABEL, 0, 0, 0); ObjectSetInteger(0, nm, OBJPROP_CORNER, CORNER_RIGHT_UPPER); ObjectSetInteger(0, nm, OBJPROP_ANCHOR, ANCHOR_RIGHT_UPPER); ObjectSetInteger(0, nm, OBJPROP_XDISTANCE, 10); ObjectSetInteger(0, nm, OBJPROP_YDISTANCE, 18); ObjectSetInteger(0, nm, OBJPROP_FONTSIZE, 9); ObjectSetString(0, nm, OBJPROP_FONT, "Consolas"); ObjectSetInteger(0, nm, OBJPROP_SELECTABLE, false); ObjectSetInteger(0, nm, OBJPROP_HIDDEN, true); } ObjectSetString(0, nm, OBJPROP_TEXT, txt); //--- Colour carries the verdict so the line can be read without parsing it: green/red only when the //--- vote would actually place an order, grey otherwise. Not green-for-buy - that would make a //--- below-threshold buy look like a trade, which is the specific misreading this display exists to //--- prevent. //--- Prospective reads dimmer than "no trade" so the two are never confused at a glance. ObjectSetInteger(0, nm, OBJPROP_COLOR, fires ? (vote > 0.0 ? clrLime : clrRed) : (prospective ? clrDimGray : clrSilver)); } //+------------------------------------------------------------------+ //| Repaint the readout from the CURRENT prospective vote. | //| | //| THE CADENCE BUG THIS EXISTS FOR: the readout used to be written | //| only inside Direction(), and with Expert_EveryTick=false the stock | //| CExpert::Refresh() gates Processing() - and therefore Direction() -| //| to NEW-BAR ticks. On an H4 chart that is one repaint every four | //| hours: the label was written once at attach (before any model had | //| produced a decision, so it read 0.0) and then sat frozen while the | //| models trained underneath it. "Stuck at 0" was the label's refresh | //| rate, not the vote's value. | //| | //| Called from OnTimer via CExpertCustom, so the readout tracks the | //| models at timer cadence. It defers to the trade path's own display | //| whenever the last real Direction() had live voters - a live vote | //| is authoritative for its whole bar, and repainting prospective | //| numbers over it would overwrite a tradable reading with an | //| untradable one. Cheap by construction: a handful of filters, plain | //| arithmetic on already-computed members, no indicator reads. | //+------------------------------------------------------------------+ void CExpertSignalCustom::RefreshVoteReadout(void) { int total = m_filters.Total(); if(total <= 0) return; // leaf filter: the readout belongs to the aggregate alone //--- PER-MEMBER NEURON LINES, rendered BEFORE the live-vote defer below: the defer protects the //--- aggregate VOTE line (a tradable reading must not be repainted with an untradable one), but //--- the member lines are not tradable readings in the first place - they are the training //--- telemetry, and freezing them for a whole bar because a live vote exists would re-create the //--- exact only-moves-once-per-era staleness they were built to end. int hudLine = 0; for(int hi = 0; hi < total; hi++) { CExpertSignalCustom *hf = m_filters.At(hi); if(hf == NULL || (m_ignore & (((long)1) << hi)) != 0) continue; string hudTxt = hf.DisplayHudLine(); if(hudTxt == "") continue; // classic ladders and veto filters draw no neuron line //--- Colour = the member's own current direction (muted tones - these are opinions, not //--- orders; the vote line's strict green-only-when-it-would-trade rule stays untouched). double hv = 0.0, hw = 0.0; hf.ProspectiveVote(hv, hw); // cached: the throttled forward already ran inside DisplayHudLine if((m_invert & (((long)1) << hi)) != 0) hv = -hv; string nm = VOTE_HUD_PREFIX + StringFormat("_m%02d", hudLine); if(ObjectFind(0, nm) < 0) { ObjectCreate(0, nm, OBJ_LABEL, 0, 0, 0); ObjectSetInteger(0, nm, OBJPROP_CORNER, CORNER_RIGHT_UPPER); ObjectSetInteger(0, nm, OBJPROP_ANCHOR, ANCHOR_RIGHT_UPPER); ObjectSetInteger(0, nm, OBJPROP_XDISTANCE, 10); ObjectSetInteger(0, nm, OBJPROP_YDISTANCE, 34 + 14 * hudLine); ObjectSetInteger(0, nm, OBJPROP_FONTSIZE, 8); ObjectSetString(0, nm, OBJPROP_FONT, "Consolas"); ObjectSetInteger(0, nm, OBJPROP_SELECTABLE, false); ObjectSetInteger(0, nm, OBJPROP_HIDDEN, true); } ObjectSetString(0, nm, OBJPROP_TEXT, hudTxt); ObjectSetInteger(0, nm, OBJPROP_COLOR, (hv > 0.0) ? clrMediumSeaGreen : (hv < 0.0 ? clrIndianRed : clrSilver)); hudLine++; } for(int hd = hudLine; hd < m_hudMemberLines; hd++) ObjectDelete(0, VOTE_HUD_PREFIX + StringFormat("_m%02d", hd)); m_hudMemberLines = hudLine; 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. //--- The sweep walks from the high index (oldest) down to the low one, so: //--- m_overlayIndex = where it STARTS = the oldest bar to reconstruct; //--- m_overlayStopIndex = where it STOPS = the most recent bar to reconstruct. //--- //--- These were previously in two different coordinate systems: the start was a series index but //--- the floor was computed as `barsAvail - span`, which is a count from the OLDEST end. On a //--- 15,049-bar chart that made the floor 10,049 against a start of 5,000, so //--- `m_overlayIndex >= m_overlayStopIndex` was false on the first test and the sweep completed //--- having touched nothing - "Filtered view: swept 0 bar(s)", on a chart whose models were //--- reporting thousands of held-out fires in the same second. The census line existed only //--- because a blank chart could not previously say why; it is what made this findable at all. //--- //--- The 150-bar margin is the INDICATOR WARM-UP at the far end of history: reads there return //--- EMPTY/garbage and would fabricate classic patterns rather than replay them. It is a cap on //--- how far BACK the start may reach, which is a bound on the same axis - the previous code //--- applied it to the floor, where it could only ever be wrong. Same margin as the META sweep. 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_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; //--- 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. The observed failure: a peak of 50 //--- frozen on the label for hours - a fossil of the 25/50/75/100 DEFAULT tier weights from the //--- attach window before the first re-rank, unreachable ever since the weights became measured //--- (pooled 27-32). A ceiling that nothing can reach reads as "the models are underperforming //--- their own history", which is exactly backwards - the history was priced in different money. m_votePeak = 0.0; m_overlayPending = true; } //+------------------------------------------------------------------+ //| RECONSTRUCT what the filtered view would have shown, one chunk | //| per call. Returns true while there is more to do. | //| | //| This answers "how would the whole bot have traded" for the bars | //| BEHIND the moment the EA started, which the forward path cannot | //| reach - CheckOpenPosition only ever runs on the bar in front of | //| it, so without this the chart is blank until the model deploys, | //| which on a multi-hour training run is the entire time you are | //| looking at it. | //| | //| WHAT IT REPRODUCES, exactly: the weighted mean over voting | //| filters, on the same 0-100 win-rate currency, against the same | //| Min_Vote_Open. AI members contribute their CACHED per-bar | //| decision from the era scan (no re-inference - the cache is | //| already the whole chart); classic ladders are replayed with | //| EvalShift(i), which is the same mechanism CSignalMETA's candidate | //| sweep uses and is exact, because every classic pattern condition | //| anchors on StartIndex(). | //| | //| WHAT IT CANNOT REPRODUCE, and this is why its arrows stop at the | //| handover point rather than continuing over live bars: order- | //| parameter validation. A reconstruction has no broker stops level, | //| no ATR warm-up state and no swing-history sync as they were at | //| that moment, so it cannot know an order was rejected. It is | //| therefore an upper bound on what would have traded - honest about | //| the vote, optimistic about placement - and it must never be | //| allowed to repaint a bar the forward path already ruled on. | //+------------------------------------------------------------------+ 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 total = m_filters.Total(); int processed = 0; while(m_overlayIndex >= m_overlayStopIndex && processed < barBudget) { //--- STOP CHECK PER BAR, not per slice. MetaTrader's ~4,500 ms teardown budget is measured from //--- the stop REQUEST and OnDeinit cannot begin until whatever is in flight returns, so every bar //--- replayed after _StopFlag is raised comes straight out of the chart cleanup - and this loop //--- runs Direction() on every classic filter per bar, which is real indicator work, not a cheap //--- array walk. The slice bound alone is not a stop check: it bounds throughput, not latency. //--- Abandoning mid-sweep costs nothing that matters - the overlay is a reconstruction and is //--- rebuilt from scratch on the next attach. 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 num = 0.0, den = 0.0; 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. The live cache is wiped to sentinel at every era start //--- and only refilled when pass 3 completes - so a sweep landing mid-era saw NO voters //--- on any bar, and (before the den==0 guard below) deleted every arrow the previous //--- sweep had drawn. Measured 2026-08-18: "drew 491" at 21:40, "0 had a voter" at //--- 21:42, "drew 382" at 21:56 - a draw/wipe cycle the user caught in its blank phase. //--- The snapshot is copied at pass-3 completion (RankTiersFromOos), so every sweep sees //--- each member's last COMPLETED era regardless of what the training passes are doing. //--- (Frame note: the snapshot is indexed in its own era-end bar frame; an H4 bar closing //--- between snapshot and sweep shifts it one index - one bar of display skew, at most, //--- for a reconstruction that is approximate by definition.) //--- hasData is true for a snapshotted NEUTRAL too - under consensus a Neutral member //--- dilutes the bar's vote, exactly as live. hasData = filter.SnapshotVoteAt(idx, contribution); } else if(filter.GetPatternCount() <= 0) continue; // veto filter (news/session/risk guard) - see below: no vote, no replay else { //--- Replay, with the live journaling state saved across it - see SaveVoteState(). //--- //--- ONLY PATTERN-LADDER FILTERS ARE REPLAYED. The veto filters (news, session, risk //--- guard) keep m_pattern_count at its 0 default - the same test UpdateSignalsWeights //--- keys on - and they contribute no weighted vote, only a prohibition. Replaying them //--- is worse than useless on two counts, both measured on 2026-08-18: //--- * the news filter calls CalendarValueHistory per evaluation, and MT5's calendar //--- cannot answer more than ~30 days back (see project memory: the calendar cliff) //--- - so every historical bar logged a failure line. 15,508 of them in 68 seconds, //--- ~230/second, which is also real wall-clock spent inside a chunked sweep whose //--- whole point is to stay cheap; //--- * a prohibition cannot be reconstructed faithfully anyway - it belongs to the //--- same cannot-replay family as order validation (see the function header), so //--- skipping it is the honest choice, not just the fast one. 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 } if(!hasData) continue; // no snapshot entry: this member says nothing about this bar if((m_invert & mask) != 0) contribution = -contribution; num += contribution; den += filter.ModuleWeight(); // consensus: capable weight, abstainers dilute } double net = (den > 0.0) ? (num / den) : 0.0; //--- 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 && 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. Leave whatever stands; only an actual sub-threshold //--- vote (the else-branch below) may take an arrow down. if(den <= 0.0) continue; //--- The direction policy (LONG_ONLY/SHORT_ONLY, or the Intelligent drift verdict) 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. //--- The META GATE is deliberately NOT replayed here (unlike the era verdict, which is the //--- certification authority and does replay it): scoring every reconstructed bar would run //--- a meta forward per chart bar on the display path - the same veto-filter-in-replay class //--- the classic overlay already skips (calendar cliff). The overlay may therefore show a //--- vote arrow the live gate would have vetoed; the era line's metaGate counts are the //--- honest number. if(MathAbs(net) >= m_threshold_open && WarriorDirectionAllows(net > 0.0)) { bool isBuy = (net > 0.0); //--- 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. The sweep walks strictly oldest -> newest (idx descending), so an //--- online pass is exact: a same-direction bar within the window of the previous SEEN //--- same-direction bar is suppressed (runs collapse to their first bar); a cross-direction //--- bar within the window of the last KEPT arrow keeps only the stronger side. Suppressed //--- bars DELETE any arrow standing from an earlier sweep - suppression is a verdict, //--- unlike the den==0 skip above. 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) { ObjectDelete(0, SIG_VOTE_PREFIX + TimeToString(bt)); continue; } if(m_overlayNmsKeptIdx >= 0 && (m_overlayNmsKeptIdx - idx) <= OVERLAY_NMS_WINDOW && m_overlayNmsKeptBuy != isBuy) { if(MathAbs(net) <= m_overlayNmsKeptNet) { ObjectDelete(0, 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) ObjectDelete(0, 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++; 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 ObjectDelete(0, SIG_VOTE_PREFIX + TimeToString(bt)); } if(m_overlayIndex < m_overlayStopIndex) { m_overlayPending = false; //--- SAY WHY THE CHART LOOKS THE WAY IT DOES. A filtered view with no arrows is a perfectly //--- legitimate answer - it means nothing cleared Min vote to open - but it is //--- INDISTINGUISHABLE on screen from a broken feature, and this project has already spent //--- two days reading an unreachable gate as a merely unmet one. 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. "0 arrows, best 41.3 vs threshold 50" //--- is a finding about the models; "0 arrows, 0 bars with a voter" is a finding about the //--- plumbing, and they need different fixes. //--- See m_overlayLastLogDrawn: print on RESULT change, every 10th sweep, or VerboseMode. 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; Print(StringFormat("Filtered view: swept %d bar(s), %d had a voter (%d buy / %d sell), drew %d" " arrow(s). Strongest vote %.1f%% against a %.1f%% threshold.%s", m_overlaySweptBars, m_overlayVotedBars, m_overlayVotedBuy, m_overlayVotedSell, m_overlayDrawn, m_overlayBestNet, m_threshold_open, (m_overlayVotedBars == 0 ? " No member has a completed era yet (snapshots fill at each member's first" " pass-3 completion) and every classic signal is disabled." : (m_overlayDrawn == 0 ? " The models voted but never strongly enough; this is the vote" " failing the bar, not the drawing failing." : "")))); } return false; } return true; } //+------------------------------------------------------------------+ //| Helper function to compare two datetime values | //+------------------------------------------------------------------+ bool IsEarlier(const SignalInfo& a, const SignalInfo& b) { datetime dtA = MakeDateTime(a); datetime dtB = MakeDateTime(b); return dtA < dtB; } //+------------------------------------------------------------------+ //| Selection sort for sorting SignalInfo array by datetime | //+------------------------------------------------------------------+ void SelectionSort(SignalInfo &signals[], int size) { for(int i = 0; i < size - 1; i++) { int min_idx = i; for(int j = i + 1; j < size; j++) { if(IsEarlier(signals[j], signals[min_idx])) { min_idx = j; } } if(min_idx != i) { // Swapping the elements SignalInfo temp = signals[i]; signals[i] = signals[min_idx]; signals[min_idx] = temp; } } } //+------------------------------------------------------------------+ //| Helper function to create a sortable datetime value | //+------------------------------------------------------------------+ datetime MakeDateTime(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); } //+------------------------------------------------------------------+ //| Process the signal and update trades | //+------------------------------------------------------------------+ void CExpertSignalCustom::ProcessBufferedSignals() { // Sort the signals array by datetime before processing SelectionSort(signalBuffer, ArraySize(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. The shrinkage target is this filter's OWN aggregate win rate across every //--- pattern/direction table it owns - not a fixed 50%, which would drag a genuinely skilled //--- model's tiers toward chance, and not a global pool, which would mix filters that trade //--- different things. 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. A filter with no history at //--- all yields poolWeight 0, which turns shrinkage off for it - correct: there is nothing to //--- shrink toward yet, and the per-tier MIN_TRADES_FOR_WIN_RATE floor still applies. 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. Tying the prior's strength to the same constant that decides whether a tier is //--- measurable at all keeps the two thresholds from drifting apart. 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. Its module weight is its POOLED HELD-OUT win //--- rate, set at each era end; overwriting that with an accumulation over live rows from //--- older models is the same clobber ApplyPatternWeight() declines one level down, and //--- guarding only the tiers while leaving this open would have let the ranking pass undo //--- half the self-ranking every hour. if(moduleWeight > 0 && moduleWeight <= 1 && !filter.SelfRanked()) { filter.Weight(moduleWeight); PrintVerbose("Applied " + filterID + " Main Weight " + DoubleToString(moduleWeight, 2)); } if(validPatternCount > 0) { sumModuleWeight += normalizedWinRate; weightedFilterCount++; } } // Track the overall DB win-rate confidence across all filters, so it can be // combined with (or used instead of) AI confidence via Confidence_Source. 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; //--- SHRINKAGE toward the pooled rate across this filter's own patterns (empirical Bayes / additive //--- smoothing: a Beta prior of priorWeight pseudo-trades centred on priorPct). Without it, the raw //--- ratio is the maximum-likelihood estimate, and at MIN_TRADES_FOR_WIN_RATE samples that estimate //--- has a standard error of ~15 percentage points - so a tier that happens to go 8-2 is handed a //--- weight of 80 and outranks a tier measured over hundreds of calls at 55. The weights are a //--- RANKING, and the ranking was being driven by which small tier got lucky. Shrinking by sample //--- size is the standard correction: a tier at the minimum count is pulled most of the way back to //--- the pool, a tier with many multiples of it is barely moved, and the ordering among //--- well-measured tiers is untouched. Same shrinkage doctrine the EdgeFinder module uses. //--- Caller passes the pool it belongs to; a caller with no pool passes priorWeight 0 and gets the //--- old raw behaviour, so this is opt-in per call site rather than a silent global change. double rate = 100.0 * wins / totalTrades; if(priorWeight > 0 && priorPct >= 0.0) rate = (wins + priorWeight * (priorPct / 100.0)) * 100.0 / (totalTrades + priorWeight); return NormalizeWinRate(rate); } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ int CExpertSignalCustom::NormalizeWinRate(double winRate) { return (int)MathRound(winRate / 10) * 10; // Round to the nearest 10 } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ void CExpertSignalCustom::OnTickHandler(void) { int total = m_filters.Total(); 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; filter.OnTickHandler(); } } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ void CExpertSignalCustom::OnChartEventHandler(const int id, const long &lparam, const double &dparam, const string &sparam) { int total = m_filters.Total(); 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; filter.OnChartEventHandler(id, lparam, dparam, sparam); } } //+------------------------------------------------------------------+