Warrior_EA/Expert/ExpertSignalCustom.mqh
AnimateDread b4a704d309 feat(ai): triple-barrier labels replace exact-pivot ZigZag targets
The 31:1 class imbalance was self-inflicted by the TARGET, not a property
of the market. Labelling only the exact bar where a ZigZag pivot confirms
gave Buy 1164 / Sell 1164 / Neutral 35841, and every correction mechanism
this codebase accumulated sits downstream of that one choice: the
logit-adjusted loss and its range cap, the prior EMA, the +-3.0 output-bias
seed, balanced-accuracy-then-precision selection with its coverage floor,
the recall floor and its catch-22, the alternation gate, NMS, and the four
oversampling designs that collapsed before them.

The reference this engine is built on (references/neuronetworksbook.pdf
ch. 3.1/3.3) also uses ZigZag, but targets the DIRECTION TO THE NEXT
EXTREMUM on every bar - ~50/50 by construction, with no imbalance to
correct at all. It never had this problem because it never asked "is this
the pivot bar".

Labels are now the triple barrier (Lopez de Prado ch. 3), using the EA's
OWN SL_Mode/TP_Mode: does a trade opened at this bar's close reach its
target before its stop, within a horizon. Buy = long resolves, Sell =
short resolves, Neutral = neither. Consequences:

- dir-precision in the era line stops being a proxy and becomes the win
  rate of the strategy under its own exit rules.
- Expected balance ~25/25/50 at the shipped 1:3 (gambler's ruin), i.e.
  ~2:1 instead of 31:1. Measured and logged at the end of the prebuild.
- Spread is charged on both legs, so it is a NET win rate.
- Intrabar ambiguity resolves to the STOP. OHLC cannot order two touches
  inside one bar and the optimistic reading is how a backtested edge
  becomes a live loss.

ZigZag stays as input features (EnableSwingContext) and now also supplies
the vertical barrier: the horizon is the median confirmed leg length,
snapped to a coarse ladder. Derived, not configured, and deliberately kept
out of the filename fingerprint - a filename keyed on a measured quantity
orphans a trained model the moment the measurement moves.

Removed, because the premise died with the old target:
- the alternation gate. Correct for pivot labels (a ZigZag cannot emit two
  same-type pivots in a row, so a repeat was provably a false fire), and
  wrong for barrier labels, which answer each bar independently. It also
  took its worst consequence with it: a one-sided model previously got ONE
  trade per backtest, a hard blocker on marketplace validation.
- SignalClusterWindow now defaults off - it de-duplicated repeats that are
  now real trades. Kept as an opt-in display control.
- LABEL_WINDOW_BARS, the pivot-widening pass, ConfirmedZigZagLabel.
- the era-0 output-bias seed now needs a genuinely dominant class (0.70)
  rather than 0.40; at ~50% Neutral a +-3.0 seed is a distortion, not a
  correction.

Also fixed, both found while wiring the above:

1. RefreshConvergedSignal sized its buffers from a date delta
   (Bars(sym, period, dtStudied, TimeCurrent())). dtStudied is a training
   watermark; in the tester it is loaded from a live-chart save AHEAD of
   the simulated date, so the interval inverted, Bars() returned ~0, and
   the buffer came out at exactly m_historyBars - deep enough for the OHLC
   window and far too shallow for the Donchian-50 / 20-bar-return / SMA
   extension behind it. Inference silently computed DIFFERENT features
   from the ones training learned on, live as well as in the tester. Now
   sized from what the feature builder actually needs.

2. The barrier horizon is resolved on the deployed path too. A deployed
   model never enters Train(), so it never reached the prebuild, and
   OnlineLearnStep reads the horizon as its confirmation delay - left at
   the fallback it would have backpropped bars whose barriers had not
   resolved. Silent lookahead in the one place that writes to a live model.

SL_Mode/TP_Mode join the weights fingerprint: they define the labels now,
so a model trained at 1:3 must never be silently reused at 1:1. This
re-keys every pre-existing model by design - none were trained on this task.

Inference census extended with the vote gate. LongCondition/ShortCondition
open with a readiness check the refresh counters never see; in the tester it
reduces to "the seeded _optcache.nnw must have LOADED", and if it did not,
every vote is hard-zeroed while the model still answers Buy. The old three
counters would have read that as "the model says Neutral" - false, and a
completely different fix. This is the leading candidate for the
zero-direction backtest and the census can now name it in one run.

Both builds compile 0 errors / 0 warnings. Forces a full retrain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:39:49 -04:00

1422 lines
77 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"
//
#define MAX_TABLE_ROWS 1000 // row cap before the oldest entry is pruned
#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 the default 1:2 Min_Risk_Reward_Ratio rejection. That is the
//--- same interaction that once rejected 100% of setups on every symbol (see TP_INTELLIGENT_BASE_RR).
#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 Min_Risk_Reward_Ratio: with confidence pinned at 0 (i.e. 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, and makes the default 1:2 rejection filter satisfiable by construction.
//--- Min_Risk_Reward_Ratio stays a pure REJECTION filter - it is never used to size TP here; it
//--- simply stops firing against this mode unless the user raises it above the base RR.
#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:
bool FetchTradeRecords(string tableName, TradeRecord &tradeRecords[]);
bool ShouldDeleteOldestEntry(TradeRecord &tradeRecords[]);
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 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);
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);
string PatternTableName(string filterID, string pattern, string direction);
string PatternName(int patternIndex) { return "Pattern_" + IntegerToString(patternIndex); }
SignalInfo signalBuffer[];
protected:
bool m_prohibition_signal;
bool m_useDatabase;
CiATR m_ATR; // ATR indicator
string m_id;
string m_active_pattern;
string m_active_direction;
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
double m_min_risk_reward_ratio; // Minimum reward:risk to open a trade - REJECTION FILTER ONLY, never shapes TP
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.
double m_ai_exit_threshold;
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
// GetActiveDirection(), 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 MinRiskRewardRatio(double value) { m_min_risk_reward_ratio = value; }
void ConfidenceSource(int value) { m_confidence_source = value; }
void AIExitThreshold(double value) { m_ai_exit_threshold = value; }
int LastFiredDirection(void) { return m_lastFiredDirection; }
// 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; };
virtual string GetActivePattern(void);
virtual string GetActiveDirection(void);
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);
int CalculatePatternWinRate(string pattern, TradeRecord &tr[]);
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; };
//--- 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_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_min_risk_reward_ratio(2.0),
m_confidence_source(0),
//--- seeded unreachable (>1.0), so an instance whose AIExitThreshold() was never set from
//--- Min_Vote_Close cannot early-exit on a stale default rather than on the trader's setting
m_ai_exit_threshold(1.01),
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();
return (own != 0.0) ? own : 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;
}
double minRR = m_min_risk_reward_ratio; // REJECTION threshold only - never used to size TP
// 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.
double slMultiplier;
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(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;
}
// Min reward:risk is now ONLY a rejection filter (Min_Risk_Reward_Ratio) - it never reshapes TP.
double reward = fabs(tp - price);
// Bridged to Money\MoneyIntelligent.mqh's Kelly-criterion sizing the same way as
// EffectiveConfidence() above - refreshed regardless of outcome below, since a rejected
// setup here never reaches Money.CheckOpenLong/Short() this tick anyway.
g_TradeRewardRiskRatio = (risk > 0.0) ? reward / risk : 0.0;
if(reward < minRR * risk)
return false;
// 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)
{
bool result = false;
//--- check of exceeding the threshold value, adjusted for long/short
double directionMultiplier = isLong ? -1 : 1;
// Allowing position closing without checking the prohibition signal.
if(directionMultiplier * m_direction >= m_threshold_close)
result = true;
// AI-driven early exit: close regardless of the rule-based threshold above if the AI signal has flipped
// against the open position with at least m_ai_exit_threshold confidence. LiveSignedConfidence()
// supplies the AI signal's live value even on the non-AI aggregate/root signal this runs on, so this is
// a no-op when no AI signal is active/converged yet (it returns 0.0) or when Min_Vote_Close is Disabled
// (m_ai_exit_threshold resolves to 1.01, which no confidence magnitude can reach).
//
// 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)
{
double signed_conf = LiveSignedConfidence();
bool reversedAgainstLong = isLong && signed_conf < 0.0 && MathAbs(signed_conf) >= m_ai_exit_threshold;
bool reversedAgainstShort = !isLong && signed_conf > 0.0 && MathAbs(signed_conf) >= m_ai_exit_threshold;
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)))
{
// 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;
}
}
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 currently active pattern |
//+------------------------------------------------------------------+
string CExpertSignalCustom::GetActivePattern(void)
{
string ret = m_active_pattern;
m_active_pattern = "NULL";
return ret;
}
//+------------------------------------------------------------------+
//| Return the currently active direction |
//+------------------------------------------------------------------+
string CExpertSignalCustom::GetActiveDirection(void)
{
string ret = m_active_direction;
m_active_direction = "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()
double result = m_weight * (LongCondition() - ShortCondition());
//--- 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;
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;
}
double price = 0.0, sl = 0.0, tp = 0.0;
datetime expiration = 0;
string bias = filter.GetActiveDirection();
string filterID = filter.GetFilterID();
string pattern = filter.GetActivePattern();
//--- Only journal a pattern when the label AGREES with the net vote the filter actually cast.
//--- m_active_direction/m_active_pattern are last-writer-wins across LongCondition() then
//--- ShortCondition(), and both sides can fire on the same bar - e.g. CSignalMA with close below
//--- its MA returns Pattern_1 long AND Pattern_0 short, netting to a vote of 0 while the labels
//--- read "Sell"/"Pattern_0". Buffering off the labels alone therefore recorded a directional
//--- pattern for a bar the filter voted FLAT on, poisoning the very win-rate table
//--- UpdateSignalsWeights() feeds back into that pattern's weight. LastFiredDirection() is the
//--- signed net vote, set in this filter's own Direction(); like the labels it is read here one
//--- tick after being written, so the two are compared as of the same tick.
int filterVote = filter.LastFiredDirection();
bool labelMatchesVote = (bias == "Buy" && filterVote > 0) || (bias == "Sell" && filterVote < 0);
if(filterID != "NULL" && bias != "NULL" && pattern != "NULL" && m_useDatabase && labelMatchesVote)
{
PrintVerbose("Processing filter: " + filterID + ", Bias: " + bias + ", Pattern: " + pattern);
double newPrice = 0;
bool signalBuffered = false; // Flag to track if signal was buffered
if(bias == "Buy")
{
if(OpenLongParams(price, sl, tp, expiration))
{
newPrice = m_symbol.Ask(); // Adjust price to current ask price
signalBuffered = true; // Set flag to true as signal will be buffered
}
}
else
if(bias == "Sell")
{
if(OpenShortParams(price, sl, tp, expiration))
{
newPrice = m_symbol.Bid(); // Adjust price to current bid price
signalBuffered = true; // Set flag to true as signal will be buffered
}
}
if(signalBuffered)
{
BufferNewTickSignal(filterID, pattern, bias, gmtTime, newPrice);
}
}
double direction = filter.Direction();
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. Standard weighted voting only - no quorum gate.
if(!aborted)
{
for(int i = 0; i < total; i++)
{
double direction = directions[i];
if(direction == EMPTY_VALUE || direction == 0)
continue;
CExpertSignalCustom *filter = m_filters.At(i);
number++; // Only increment `number` if `direction` is not 0 or EMPTY_VALUE and not suppressed
long mask = ((long)1) << i;
result += ((m_invert & mask) != 0) ? -direction : direction;
}
}
//--- Normalization, as CExpertSignal::Direction() does it: the weighted votes are AVERAGED over the
//--- filters that actually voted, not summed. `number` was being counted here and then never used,
//--- which left result as a raw sum - two ordinary agreeing votes (e.g. MA's 60 + RSI's 100) could
//--- exceed the +-100 valid band and get zeroed by the range check below, throwing away exactly the
//--- strongest, most agreed-upon setups. Only non-zero, non-suppressed contributions increment
//--- `number` (see pass 2), so a lone filter voting 10 still normalizes to 10 and can clear a
//--- ThresholdOpen(10) on its own - averaging does not raise the bar for a single-voter signal.
if(!aborted && number != 0)
result /= number;
//--- 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.");
}
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)
{
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};
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");
}
// Fetch trade records for both directions
TradeRecord tradeRecordsCurrent[], tradeRecordsOpposite[];
if(!FetchTradeRecords(currentTableName, tradeRecordsCurrent))
{
Print("Failed to fetch current direction trades from: " + currentTableName);
return; // Fail to fetch current direction trades
}
if(!FetchTradeRecords(oppositeTableName, tradeRecordsOpposite))
{
Print("Failed to fetch opposite direction trades from: " + oppositeTableName);
return; // Fail to fetch opposite direction trades
}
if(ShouldDeleteOldestEntry(tradeRecordsCurrent))
DeleteOldestEntry(currentTableName);
if(ShouldDeleteOldestEntry(tradeRecordsOpposite))
DeleteOldestEntry(oppositeTableName);
// Process trades in the opposite direction to close them
bool isTradeOpen = false;
for(int i = 0; i < ArraySize(tradeRecordsOpposite); i++)
{
if(tradeRecordsOpposite[i].pattern == signal.pattern && tradeRecordsOpposite[i].result == "NA")
{
// Close the opposite trade
tradeRecordsOpposite[i].exitPrice = signal.entryPrice;
double profitLoss = (tradeRecordsOpposite[i].direction == "Buy") ?
(signal.entryPrice - tradeRecordsOpposite[i].entryPrice) :
(tradeRecordsOpposite[i].entryPrice - signal.entryPrice);
tradeRecordsOpposite[i].result = profitLoss >= 0 ? "Profit" : "Loss";
UpdateTradeRecordInDatabase(oppositeTableName, tradeRecordsOpposite[i]);
PrintVerbose("Closed opposite trade: " + oppositeTableName + ", Profit/Loss: " + DoubleToString(profitLoss));
isTradeOpen = true; // Signal that a trade was handled
break; // Since it's a stop and reverse, handle only one trade at a time
}
}
// Check for open trades or duplicate entries in the current direction
for(int i = 0; i < ArraySize(tradeRecordsCurrent); i++)
{
// Check for exact duplicates first
if(tradeRecordsCurrent[i].pattern == signal.pattern &&
tradeRecordsCurrent[i].year == signal.year &&
tradeRecordsCurrent[i].month == signal.month &&
tradeRecordsCurrent[i].day == signal.day &&
tradeRecordsCurrent[i].hour == signal.hour &&
tradeRecordsCurrent[i].minutes == signal.minutes)
{
PrintVerbose("Duplicate trade found, not registering new trade. Table: " + currentTableName);
return; // Duplicate trade found, exit processing
}
// Check for outdated or same time trades
if((tradeRecordsCurrent[i].year > signal.year) ||
(tradeRecordsCurrent[i].year == signal.year && tradeRecordsCurrent[i].month > signal.month) ||
(tradeRecordsCurrent[i].year == signal.year && tradeRecordsCurrent[i].month == signal.month && tradeRecordsCurrent[i].day > signal.day) ||
(tradeRecordsCurrent[i].year == signal.year && tradeRecordsCurrent[i].month == signal.month && tradeRecordsCurrent[i].day == signal.day && tradeRecordsCurrent[i].hour > signal.hour) ||
(tradeRecordsCurrent[i].year == signal.year && tradeRecordsCurrent[i].month == signal.month && tradeRecordsCurrent[i].day == signal.day && tradeRecordsCurrent[i].hour == signal.hour && tradeRecordsCurrent[i].minutes >= signal.minutes))
{
PrintVerbose("Outdated or same time trade found, not registering new trade. Table: " + currentTableName);
return; // Outdated or same time trade found, exit processing
}
// Check if there's an open trade with the same pattern
if(tradeRecordsCurrent[i].result == "NA" && tradeRecordsCurrent[i].pattern == signal.pattern)
{
PrintVerbose("Open trade found, not registering new trade. Table: " + currentTableName + ", Pattern: " + signal.pattern);
return; // Open trade found, exit processing
}
}
// Register a new trade if no duplicates, outdated, or open trades are found
if(!isTradeOpen)
{
RegisterSignal(signal.year, signal.month, signal.day, signal.DOW, signal.hour, signal.minutes,
currentTableName, signal.pattern, signal.direction, signal.entryPrice, 0.0, "NA");
PrintVerbose("Registered new trade in table: " + currentTableName + ", Pattern: " + signal.pattern + ", Direction: " + signal.direction);
}
}
//+------------------------------------------------------------------+
//| 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.
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
bool CExpertSignalCustom::FetchTradeRecords(string tableName, TradeRecord &tradeRecords[])
{
TradeRecord tradeRecordStruct;
if(!dbm.FetchTradeRecords(tableName, tradeRecordStruct, tradeRecords))
{
Print(__FUNCTION__ + " Failed to fetch trade records from " + tableName);
return false;
}
return true;
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
bool CExpertSignalCustom::ShouldDeleteOldestEntry(TradeRecord &tradeRecords[])
{
return ArraySize(tradeRecords) >= MAX_TABLE_ROWS;
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
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)
{
string Columns[] = {"year", "month", "day", "dayOfWeek", "hour", "minutes", "pattern", "direction", "entryPrice", "exitPrice", "result"};
string valArr[] = {IntegerToString(year), IntegerToString(month), IntegerToString(day), IntegerToString(DOW), IntegerToString(hour), IntegerToString(minutes), pattern, direction, DoubleToString(entryPrice, Digits()), DoubleToString(exitPrice, Digits()), result};
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);
TradeRecord tradeRecordStruct;
int total = m_filters.Total();
double sumModuleWeight = 0.0;
int weightedFilterCount = 0;
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;
for(int j = 0; j < patternCount; j++)
{
// Fetch trade records for the specified table
string pattern = PatternName(j);
string tableNameBuy = PatternTableName(filterID, pattern, "Buy");
string tableNameSell = PatternTableName(filterID, pattern, "Sell");
TradeRecord tradeRecordsBuy[], tradeRecordsSell[];
// Fetch Buy side trade records
if(!dbm.FetchTradeRecords(tableNameBuy, tradeRecordStruct, tradeRecordsBuy))
{
Print(__FUNCTION__ + " Failed to fetch trade records from " + tableNameBuy);
continue;
}
// Fetch Sell side trade records
if(!dbm.FetchTradeRecords(tableNameSell, tradeRecordStruct, tradeRecordsSell))
{
Print(__FUNCTION__ + " Failed to fetch trade records from " + tableNameSell);
continue;
}
int winRateBuy = CalculatePatternWinRate(pattern, tradeRecordsBuy);
int winRateSell = CalculatePatternWinRate(pattern, tradeRecordsSell);
// 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;
if(moduleWeight > 0 && moduleWeight <= 1)
{
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);
}
//+------------------------------------------------------------------+
//| Calculate the time based win rate for specified pattern |
//+------------------------------------------------------------------+
int CExpertSignalCustom::CalculatePatternWinRate(string pattern, TradeRecord & tr[])
{
int totalTrades = 0;
int profitableTrades = 0;
MqlDateTime gmtTime;
TimeGMT(gmtTime);
if(IsBacktesting)
{
datetime nowGmt = StructToTime(gmtTime);
for(int i = ArraySize(tr) - 1; i >= 0; i--)
{
MqlDateTime recordTime;
recordTime.year = tr[i].year;
recordTime.mon = tr[i].month;
recordTime.day = tr[i].day;
recordTime.hour = tr[i].hour;
recordTime.min = tr[i].minutes;
recordTime.sec = 0;
if(StructToTime(recordTime) >= nowGmt)
ArrayResize(tr, ArraySize(tr) - 1);
else
break;
}
}
// Loop through trade records
for(int i = 0; i < ArraySize(tr); i++)
{
if(tr[i].pattern == pattern && tr[i].result != "NA")
{
//--- was previously also requiring hour/day/day_of_week/month to all match the CURRENT moment
//--- (gmtTime) simultaneously - a coincidence real trade history essentially never satisfies,
//--- which made this always return NO_DATA_WIN_RATE regardless of actual history. Win rate is
//--- per-pattern, not per-exact-timestamp, so the pattern/result match above is the only filter.
totalTrades++; // Increment total trades
if(tr[i].result == "Profit")
profitableTrades++;
}
}
// Check if total trades meet the minimum requirement
if(totalTrades < MIN_TRADES_FOR_WIN_RATE)
return NO_DATA_WIN_RATE;
// Calculate win rate based on the selected trading style
double winRate = 0.0;
winRate = (totalTrades > 2) ? (double)profitableTrades / totalTrades * 100.0 : 0.0;
// Normalize and return win rate
return NormalizeWinRate(winRate);
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
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);
}
}
//+------------------------------------------------------------------+