forked from mnbvc188199/Warrior_EA
Era-680 report, all three observations one equation: "peak 29, no arrows at
threshold 30" / "at 20, arrows on EVERY bar" / "label at 12 while arrows
everywhere". Under the voters-only divisor, any bar with at least one
directional voter read the weighted mean of the firing tiers' weights - and
once the tiers self-ranked to each model's pooled win rate (~28-31), that
mean was NEAR-CONSTANT regardless of headcount. One member alone: ~29. Four
unanimous: ~29. Min_Vote_Open was a step function around that constant -
above it nothing ever fired, below it everything did - and the label's 12
was a 3v1 split netting through the same divisor. Not three display bugs:
one arithmetic that could not express agreement.
The divisor is now the CAPABLE weight - every filter that could vote,
whether it did or not:
* live (Direction): VoteCapableWeight() - classic pattern ladders always,
veto filters never, AI members once past the same readiness test
LongCondition gates on. A model still training must not dilute an
ensemble it cannot join: four trainees + one deployed model is a solo
chart wearing an ensemble label, and the solo vote reads full strength.
* gate (EnsembleEraVerdict): g_ensVoteWeightSum accumulates for every
member that EVALUATED the bar, Neutral included.
* overlay sweep + prospective readout: weight counts whenever the member
has data; a snapshotted Neutral dilutes.
One arithmetic, four sites, same numbers everywhere.
What the numbers become (four members, w~0.29, tiers~29): unanimous ~29 -
the CEILING, which is the pooled win rate and is what the peak displays;
3-of-4 ~22; 2-of-4 ~14.5; 3v1 ~14.5. Min_Vote_Open 20 now means "roughly
three-quarters of the ensemble's trust agrees, net". It MUST sit below the
ceiling to ever fire - the census/peak states the ceiling.
This is the ensemble the user specified in the original design discussion
("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, kept until measurement showed its
vote magnitude was a constant.
Plus overlay DECLUSTERING, the other half of "arrows on every bar": the
same three NMS rules as the per-member arrows (same-direction runs collapse
to their first bar, cross-direction flicker keeps the stronger side), online
over the sweep's strictly oldest->newest walk. Suppression is a verdict and
deletes a standing arrow; the den==0 no-data skip still never does.
NOT COMPILED - user compiles in MetaEditor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2427 lines
145 KiB
MQL5
2427 lines
145 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| Warrior_EA |
|
|
//| AnimateDread |
|
|
//| |
|
|
//+------------------------------------------------------------------+
|
|
#include <Expert\ExpertSignal.mqh>
|
|
#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_"
|
|
//--- 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 an arrow object is actually created. Deliberately a |
|
|
//| free function rather than a method: three unrelated callers need |
|
|
//| it (a classic filter, the aggregate signal's vote layer, and |
|
|
//| Warrior_EA.mq5's historical overlay rebuild) and only one of them |
|
|
//| is a signal object at all. |
|
|
//| |
|
|
//| No ObjectFind() pre-check, for the reason CExpertSignalAIBase:: |
|
|
//| DrawObject() documents at length: ObjectFind scans the entire |
|
|
//| chart object list, so calling it per drawn arrow makes a full |
|
|
//| redraw O(n^2) in the arrow 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 WarriorPlotArrow(const string name, const datetime t, const double price, const bool isBuy,
|
|
const color clr, const int arrowCode, const string tooltip)
|
|
{
|
|
if(t <= 0 || !MathIsValidNumber(price) || price <= 0.0)
|
|
return;
|
|
ObjectCreate(0, name, OBJ_ARROW, 0, t, 0);
|
|
ObjectSetDouble(0, name, OBJPROP_PRICE, price);
|
|
ObjectSetInteger(0, name, OBJPROP_ARROWCODE, arrowCode);
|
|
ObjectSetInteger(0, name, OBJPROP_COLOR, clr);
|
|
ObjectSetInteger(0, name, OBJPROP_ANCHOR, isBuy ? ANCHOR_TOP : ANCHOR_BOTTOM);
|
|
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
|
|
//
|
|
#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& gmtTime, 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;
|
|
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;
|
|
//--- 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 GMT timestamp, 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; }
|
|
//--- 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; }
|
|
//--- 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);
|
|
double price = isBuy ? iLow(m_symbol.Name(), m_period, idx) : iHigh(m_symbol.Name(), m_period, idx);
|
|
WarriorPlotArrow(FilterArrowPrefix() + TimeToString(t), t, price, isBuy,
|
|
isBuy ? clrDodgerBlue : clrTomato, isBuy ? 233 : 234,
|
|
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);
|
|
double price = isBuy ? iLow(m_symbol.Name(), m_period, idx) : iHigh(m_symbol.Name(), m_period, idx);
|
|
WarriorPlotArrow(SIG_VOTE_PREFIX + TimeToString(t), t, price, isBuy,
|
|
isBuy ? clrLime : clrRed, isBuy ? 225 : 226,
|
|
StringFormat("TRADE %s | vote %.1f >= %.1f | SL %s TP %s",
|
|
(isBuy ? "BUY" : "SELL"), 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_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_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<CExpertSignalCustom*>(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;
|
|
}
|
|
//--- 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;
|
|
//--- 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
|
|
if(tradingdirection == LONG_ONLY || tradingdirection == BOTH)
|
|
{
|
|
return CheckOpenPosition(true, price, sl, tp, expiration);
|
|
}
|
|
// If the strategy is SHORT_ONLY, prevent opening a long position
|
|
if(ShouldTraceTradeRejections())
|
|
TraceSignalRejection("open-long-direction-block",
|
|
StringFormat("%s: open long rejected - strategy direction blocks long entries.", __FUNCTION__));
|
|
return false;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Generating a sell signal |
|
|
//+------------------------------------------------------------------+
|
|
bool CExpertSignalCustom::CheckOpenShort(double &price, double &sl, double &tp, datetime &expiration)
|
|
{
|
|
// Check if the trading strategy allows opening short positions
|
|
if(tradingdirection == SHORT_ONLY || tradingdirection == BOTH)
|
|
{
|
|
return CheckOpenPosition(false, price, sl, tp, expiration);
|
|
}
|
|
// If the strategy is LONG_ONLY, prevent opening a short position
|
|
if(ShouldTraceTradeRejections())
|
|
TraceSignalRejection("open-short-direction-block",
|
|
StringFormat("%s: open short rejected - strategy direction blocks short entries.", __FUNCTION__));
|
|
return false;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Return the long ladder's matched pattern (consuming read) |
|
|
//+------------------------------------------------------------------+
|
|
string CExpertSignalCustom::GetActivePatternLong(void)
|
|
{
|
|
string ret = m_active_pattern_long;
|
|
m_active_pattern_long = "NULL";
|
|
return ret;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Return the short ladder's matched pattern (consuming read) |
|
|
//+------------------------------------------------------------------+
|
|
string CExpertSignalCustom::GetActivePatternShort(void)
|
|
{
|
|
string ret = m_active_pattern_short;
|
|
m_active_pattern_short = "NULL";
|
|
return ret;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
//| Detecting the "weighted" direction |
|
|
//+------------------------------------------------------------------+
|
|
double CExpertSignalCustom::Direction(void)
|
|
{
|
|
MqlDateTime gmtTime;
|
|
datetime nowGMT = TimeGMT(gmtTime); // 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
|
|
//--- TimeGMT().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(nowGMT != m_directionCurrentSecond)
|
|
{
|
|
m_directionAggregatedResult = 0.0;
|
|
m_directionCount = 0;
|
|
m_directionCurrentSecond = nowGMT; // 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", gmtTime, m_symbol.Ask(), filterNetVote);
|
|
if(patternShort != "NULL")
|
|
BufferNewTickSignal(filterID, patternShort, "Sell", gmtTime, 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& gmtTime, 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 = {gmtTime.year, gmtTime.mon, gmtTime.day, gmtTime.day_of_week, gmtTime.hour, gmtTime.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.
|
|
bool fires = (mag >= m_threshold_open) && (voters > 0) && !prospective;
|
|
//--- An all-neutral read is "flat", not "--": the models answered, and the answer was Neutral.
|
|
//--- "--" is reserved for genuinely nobody-home (no decisions at all). Reported 2026-08-18 as
|
|
//--- "not seeing neutrals" - Neutral members abstained invisibly behind a count that never
|
|
//--- distinguished them from voters.
|
|
string dir = (voters <= 0) ? (neutrals > 0 ? "flat" : "--")
|
|
: (vote > 0.0 ? "BUY" : (vote < 0.0 ? "SELL" : "flat"));
|
|
//--- 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, mag, 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
|
|
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;
|
|
if(MathAbs(net) >= m_threshold_open)
|
|
{
|
|
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);
|
|
double price = isBuy ? iLow(m_symbol.Name(), m_period, idx)
|
|
: iHigh(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++;
|
|
WarriorPlotArrow(SIG_VOTE_PREFIX + TimeToString(bt), bt, price, isBuy,
|
|
isBuy ? clrLime : clrRed, isBuy ? 225 : 226,
|
|
StringFormat("would trade %s | confidence %.1f%% >= %.1f%% | reconstructed"
|
|
" (vote only - order validation not replayed)",
|
|
(isBuy ? "BUY" : "SELL"), 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.
|
|
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 gmtNow;
|
|
TimeGMT(gmtNow);
|
|
long nowKey = SignalTimeKey(gmtNow.year, gmtNow.mon, gmtNow.day, gmtNow.hour, gmtNow.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);
|
|
}
|
|
}
|
|
//+------------------------------------------------------------------+
|