2026-07-18 15:53:04 -04:00 | | | //+------------------------------------------------------------------+
|
| | | //| MoneyRiskBase.mqh |
|
| | | //| AnimateDread |
|
| | | //| https://www.mql5.com |
|
| | | //+------------------------------------------------------------------+
|
| | | #include "..\Expert\ExpertMoneyCustom.mqh"
|
2026-08-02 12:25:20 -04:00 | | | #include "..\Variables\RiskBudget.mqh"
|
2026-07-18 15:53:04 -04:00 | | | //+------------------------------------------------------------------+
|
| | | //| Class CMoneyRiskBase. |
|
| | | //| Shared risk-based lot-sizing core for every money-management |
|
 refactor(trade-mgmt): remove all confidence-scaled trade management
Five modes went, all of them staking real risk on the model's confidence:
Intelligent entry (ENTRY_INTELLIGENT), stop (SL_INTELLIGENT), target
(TP_INTELLIGENT), trailing (CTrailingIntelligent) and lot size
(CMoneyIntelligent's quarter-Kelly). With them, the Confidence_Source
input and the CONFIDENCE_SOURCE enum, whose only job was choosing which
number those five read.
The reason is calibration, not correctness: the confidence magnitude is
known to be miscalibrated against the label prior, so every one of these
modes multiplied money by a quantity whose units were never established.
The DB arm had a second, independent defect - since the tester DB guard
(SignalDatabaseActive) it reads 0 in tester and optimizer but non-zero
live, so any backtest of CONF_DB/CONF_BLENDED could not reproduce live
trading. And what the DB produces is a filter-RANKING win rate, not a
per-trade win probability.
Both confidence numbers are still recorded per trade (aiConfidence /
dbConfidence) and still bucketed against outcome in TradeJournalReport.
Recording is what keeps the question answerable; acting on it was the
part with no evidence behind it. ConfidenceBridge.mqh now carries an
explicit telemetry-only rule at the top.
ENUM ORDINALS PINNED. Removing a member vacated a value in four enums at
once and MT5 does not validate an enum input replayed from a saved .set
or a stored optimization pass. TRAILING_STRATEGY and
MONEY_MANAGEMENT_STRATEGY now carry explicit values so the survivors keep
the numbers they were saved as, and ValidateBarrierInputs is widened into
ValidateTradeManagementInputs covering SL_Mode, TP_Mode,
Entry_Multiplier, TrailingStrategy and MM_STRATEGY. Without that gate a
chart saved with the Intelligent stop would feed SL_Mode = -1 into a
multiplier now used verbatim, placing the stop on the wrong side of entry.
RETRAIN-NEUTRAL: neither SL_Mode nor TP_Mode appears in
BuildModelFingerprint() or ComputeDbConfigFingerprint() since the
swing-pivot target replaced the barrier labels. No .nnw, .cfg or .db
re-keys. Also drops the now-dead g_TradeRewardRiskRatio bridge, the
CMoneyRiskBase::AdjustRiskAmount hook and the unsigned AIConfidence().
Compile-verified in _claude_stage: 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 10:10:20 -04:00 | | | //| strategy that sizes a trade off a fixed account-risk percentage. |
|
| | | //| It was extracted when CMoneyFixedRisk and CMoneyIntelligent had |
|
| | | //| CalculatePotentialLoss()/CheckOpenLong()/CheckOpenShort()/ |
|
| | | //| CalculateLotSize() duplicated near-verbatim between them; |
|
| | | //| CMoneyIntelligent was removed 2026-08-25, so CMoneyFixedRisk is |
|
| | | //| currently the only subclass. Kept as a base anyway - what lives |
|
| | | //| here is the account-risk contract (including the RiskBudget |
|
| | | //| clamp), not one strategy's arithmetic. |
|
| | | //| AdjustLotSize() is the one remaining divergence point; the |
|
| | | //| AdjustRiskAmount() hook beside it went with its only overrider. |
|
2026-07-18 15:53:04 -04:00 | | | //+------------------------------------------------------------------+
|
| | | class CMoneyRiskBase : public CExpertMoneyCustom
|
| | | {
|
| | | public:
|
| | | virtual double CheckOpenLong(double price, double sl);
|
| | | virtual double CheckOpenShort(double price, double sl);
|
| | | virtual double CheckClose(CPositionInfo *position) { return(0.0); }
|
| | |
|
| | | protected:
|
| | | double CalculatePotentialLoss(ENUM_ORDER_TYPE orderType, double price, double sl);
|
| | | double CalculateLotSize(double loss);
|
2026-08-24 01:54:46 -04:00 | | |
|
| | | private:
|
| | | //--- CheckOpenLong()/CheckOpenShort() share this whole body, differing only in the order type
|
| | | //--- passed through to CalculatePotentialLoss()/ValidateLotForTrade(); see CheckTrailingStop()
|
| | | //--- in Trailing\TrailingATR.mqh for the same isLong-parameter unification pattern.
|
| | | double CheckOpen(ENUM_ORDER_TYPE type, double price, double sl);
|
2026-07-18 17:39:58 -04:00 | | | //--- Reserved extension point for a future lot-size-level adjustment (e.g. equity-curve-
|
| | | //--- based scaling); no current subclass overrides this - default is a no-op.
|
2026-07-18 15:53:04 -04:00 | | | virtual double AdjustLotSize(double lot) { return lot; }
|
| | | };
|
| | | //+------------------------------------------------------------------+
|
| | | //| Getting lot size for open long position. |
|
| | | //+------------------------------------------------------------------+
|
| | | double CMoneyRiskBase::CheckOpenLong(double price, double sl)
|
| | | {
|
2026-08-24 01:54:46 -04:00 | | | return CheckOpen(ORDER_TYPE_BUY, price, sl);
|
2026-07-18 15:53:04 -04:00 | | | }
|
| | | //+------------------------------------------------------------------+
|
| | | //| Getting lot size for open short position. |
|
| | | //+------------------------------------------------------------------+
|
| | | double CMoneyRiskBase::CheckOpenShort(double price, double sl)
|
2026-08-24 01:54:46 -04:00 | | | {
|
| | | return CheckOpen(ORDER_TYPE_SELL, price, sl);
|
| | | }
|
| | | //+------------------------------------------------------------------+
|
| | | //| Shared CheckOpenLong()/CheckOpenShort() body - see class header. |
|
| | | //+------------------------------------------------------------------+
|
| | | double CMoneyRiskBase::CheckOpen(ENUM_ORDER_TYPE type, double price, double sl)
|
2026-07-18 15:53:04 -04:00 | | | {
|
| | | if(m_symbol == NULL)
|
| | | return 0.0;
|
2026-08-24 01:54:46 -04:00 | | | double loss = CalculatePotentialLoss(type, price, sl);
|
2026-07-18 15:53:04 -04:00 | | | if(loss <= 0.0)
|
2026-07-26 12:12:14 -04:00 | | | {
|
2026-08-24 01:54:46 -04:00 | | | // loss<=0 means sl landed on the wrong side of price (upstream signal bug, stale/gapped
|
| | | // quote, or a mid-tick price move) - reject the trade instead of silently opening at
|
| | | // LotsMin(), which used to bypass Money_Risk_Percent entirely with no trace of why.
|
2026-07-26 12:12:14 -04:00 | | | PrintFormat("%s: rejected - non-positive potential loss (%.5f) for price=%.5f sl=%.5f on %s",
|
| | | __FUNCTION__, loss, price, sl, m_symbol.Name());
|
| | | return 0.0;
|
| | | }
|
2026-07-18 15:53:04 -04:00 | | | double lot = AdjustLotSize(CalculateLotSize(loss));
|
2026-07-26 12:12:14 -04:00 | | | if(lot <= 0.0 || !MathIsValidNumber(lot))
|
| | | {
|
| | | PrintFormat("%s: rejected - invalid computed lot size (%.5f) for loss=%.5f on %s", __FUNCTION__, lot, loss, m_symbol.Name());
|
| | | return 0.0;
|
| | | }
|
2026-07-18 15:53:04 -04:00 | | | string description;
|
2026-08-24 01:54:46 -04:00 | | | // Article 2555 #14/#3/#5/#2 in one gate: tradeable symbol, legal volume on the SYMBOL_VOLUME_STEP
|
| | | // grid, inside SYMBOL_VOLUME_LIMIT for this direction, and covered by ACCOUNT_MARGIN_FREE. See
|
| | | // CExpertMoneyCustom::ValidateLotForTrade() - it logs its own (throttled) rejection reason.
|
| | | if(!ValidateLotForTrade(m_symbol.Name(), lot, type, description))
|
2026-07-26 12:12:14 -04:00 | | | return 0.0;
|
2026-07-26 23:08:32 -04:00 | | | return lot;
|
2026-07-18 15:53:04 -04:00 | | | }
|
| | | //+------------------------------------------------------------------+
|
| | | //| Calculate potential loss |
|
| | | //+------------------------------------------------------------------+
|
| | | double CMoneyRiskBase::CalculatePotentialLoss(ENUM_ORDER_TYPE orderType, double price, double sl)
|
| | | {
|
| | | if(price == 0.0)
|
| | | price = (orderType == ORDER_TYPE_BUY) ? m_symbol.Ask() : m_symbol.Bid();
|
2026-07-26 12:12:14 -04:00 | | | if(price == 0.0)
|
| | | {
|
| | | // SymbolInfoDouble-backed Ask()/Bid() returns 0 when no quote is available yet
|
| | | // (disconnected/freshly-selected symbol) - a 0 price makes OrderProfitCheck's result
|
| | | // meaningless, so fail loudly instead of feeding it into the lot-size calculation.
|
| | | PrintFormat("%s: no valid price available for %s (Ask/Bid returned 0)", __FUNCTION__, m_symbol.Name());
|
| | | return 0.0;
|
| | | }
|
2026-07-18 15:53:04 -04:00 | | | return -m_account.OrderProfitCheck(m_symbol.Name(), orderType, 1.0, price, sl);
|
| | | }
|
| | | //+------------------------------------------------------------------+
|
| | | //| Calculate the lot size based on potential loss and account balance|
|
| | | //+------------------------------------------------------------------+
|
| | | double CMoneyRiskBase::CalculateLotSize(double loss)
|
| | | {
|
2026-08-01 11:27:28 -04:00 | | | if(loss <= 0.0 || !MathIsValidNumber(loss))
|
| | | {
|
| | | // Both current callers reject a non-positive loss before reaching here, but this is where the
|
| | | // division happens, so this is where the invariant belongs - CalculatePotentialLoss() signals
|
| | | // "no usable quote" by returning exactly 0.0, and any future caller that forwards that value
|
| | | // straight through would otherwise divide by it and hand back an inf lot size.
|
| | | PrintFormat("%s: rejected - potential loss must be positive and finite, got %.5f", __FUNCTION__, loss);
|
| | | return 0.0;
|
| | | }
|
 refactor(trade-mgmt): remove all confidence-scaled trade management
Five modes went, all of them staking real risk on the model's confidence:
Intelligent entry (ENTRY_INTELLIGENT), stop (SL_INTELLIGENT), target
(TP_INTELLIGENT), trailing (CTrailingIntelligent) and lot size
(CMoneyIntelligent's quarter-Kelly). With them, the Confidence_Source
input and the CONFIDENCE_SOURCE enum, whose only job was choosing which
number those five read.
The reason is calibration, not correctness: the confidence magnitude is
known to be miscalibrated against the label prior, so every one of these
modes multiplied money by a quantity whose units were never established.
The DB arm had a second, independent defect - since the tester DB guard
(SignalDatabaseActive) it reads 0 in tester and optimizer but non-zero
live, so any backtest of CONF_DB/CONF_BLENDED could not reproduce live
trading. And what the DB produces is a filter-RANKING win rate, not a
per-trade win probability.
Both confidence numbers are still recorded per trade (aiConfidence /
dbConfidence) and still bucketed against outcome in TradeJournalReport.
Recording is what keeps the question answerable; acting on it was the
part with no evidence behind it. ConfidenceBridge.mqh now carries an
explicit telemetry-only rule at the top.
ENUM ORDINALS PINNED. Removing a member vacated a value in four enums at
once and MT5 does not validate an enum input replayed from a saved .set
or a stored optimization pass. TRAILING_STRATEGY and
MONEY_MANAGEMENT_STRATEGY now carry explicit values so the survivors keep
the numbers they were saved as, and ValidateBarrierInputs is widened into
ValidateTradeManagementInputs covering SL_Mode, TP_Mode,
Entry_Multiplier, TrailingStrategy and MM_STRATEGY. Without that gate a
chart saved with the Intelligent stop would feed SL_Mode = -1 into a
multiplier now used verbatim, placing the stop on the wrong side of entry.
RETRAIN-NEUTRAL: neither SL_Mode nor TP_Mode appears in
BuildModelFingerprint() or ComputeDbConfigFingerprint() since the
swing-pivot target replaced the barrier labels. No .nnw, .cfg or .db
re-keys. Also drops the now-dead g_TradeRewardRiskRatio bridge, the
CMoneyRiskBase::AdjustRiskAmount hook and the unsigned AIConfidence().
Compile-verified in _claude_stage: 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 10:10:20 -04:00 | | | double riskAmount = m_account.Balance() * m_percent / 100.0;
|
2026-08-02 12:25:20 -04:00 | | | //--- ACCOUNT-LEVEL CLAMP. Money_Risk_Percent alone answers "what is my usual risk per trade", which
|
| | | //--- is a different question from "how much am I still ALLOWED to lose today". Sizing off balance
|
| | | //--- with no reference to the second is how a routine stop-out breaches a hard daily limit: at 3.2%
|
| | | //--- into a 4% day, a full-size trade is already over the line before it is placed. CapRiskAmount()
|
| | | //--- reduces this to a fraction of what genuinely remains after every open position's loss-to-stop,
|
| | | //--- and returns 0 when the trade must not be taken at all. See Variables\RiskBudget.mqh.
|
| | | double allowed = g_riskBudget.CapRiskAmount(riskAmount);
|
| | | if(allowed <= 0.0)
|
| | | return 0.0; // budget exhausted / halted - CapRiskAmount has logged the reason
|
| | | riskAmount = allowed;
|
2026-07-18 15:53:04 -04:00 | | | double stepvol = m_symbol.LotsStep();
|
2026-07-26 12:12:14 -04:00 | | | if(stepvol <= 0.0)
|
| | | {
|
| | | // A 0 SYMBOL_VOLUME_STEP (not yet synced by the broker) would otherwise divide by zero and
|
| | | // propagate inf/NaN downstream into volume/margin checks with no diagnostic.
|
| | | PrintFormat("%s: rejected - LotsStep() returned %.5f for %s (broker volume data not ready?)", __FUNCTION__, stepvol, m_symbol.Name());
|
| | | return 0.0;
|
| | | }
|
 fix: four risk-layer holes a funded account would eventually find
1. The expectancy stop was stone dead at shipped defaults. Its only feed -
RecordTradeResult inside CTradeJournalManager::Update() - ran solely under
UseDatabaseRanking, which ships false, so the da54639 halt was armed
(ExpectancyMinTrades=40) and never received a single closed trade. A risk
rule must not be a side effect of an analytics toggle: the journal gains
InitTrackingOnly(), Update() runs unconditionally from OnTick and skips
only the DB insert when no DB was initialized.
2. Below-minimum lots were silently bumped UP to SYMBOL_VOLUME_MIN by
TCNormalizeVolume - correct for a user-entered fixed lot, but in the
risk-sizing path it turned a budget-capped 0.05 into 0.10 on min-0.10/
step-0.01 symbols: double the intended risk, after CapRiskAmount already
clamped, exactly the routine-stop-out-breaches-the-daily-limit scenario
the budget exists to close. CMoneyRiskBase now refuses the trade when the
risk-derived lot is below the broker minimum.
3. All trading was async fire-and-forget (SetAsyncMode(true)) with no
OnTradeTransaction handler and no retry: server retcodes were never
observed. Fail-safe for entries, not for closes - a silently rejected
close rode the position until the next bar (or next day for the timed
close window). Now synchronous, matching the risk-budget flatten's own
already-synchronous CTrade; on an H1 EA the latency is irrelevant.
4. FIXED_LOT bypassed the budget entirely (no CapRiskAmount, no
OpenRiskAtStops) - pre-halt it could commit more than the remaining daily
allowance. A fixed lot cannot be scaled, so the rule is binary: its
loss-to-stop fits the remaining allowance whole or the trade is refused;
unpriceable risk (no SL) is refused while the budget is enabled.
Compile: 0 errors, 0 warnings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:14:26 -04:00 | | | double lot = MathFloor(riskAmount / loss / stepvol) * stepvol;
|
| | | //--- BELOW-MINIMUM MEANS NO TRADE, NOT A BIGGER TRADE. Downstream, TCNormalizeVolume() bumps any
|
| | | //--- sub-minimum volume UP to SYMBOL_VOLUME_MIN (correct for a user-entered fixed lot), which on a
|
| | | //--- symbol where min > step (indices/metals: min 0.10, step 0.01) would turn a risk-derived 0.05
|
| | | //--- into 0.10 - double the intended risk, AFTER CapRiskAmount() already clamped it. That is the
|
| | | //--- exact "routine stop-out breaches the daily limit" path the budget exists to close, and the
|
| | | //--- opposite of RISK_BUDGET_MIN_SIZE_FRACTION's near-binary rule (see Variables\RiskBudget.mqh):
|
| | | //--- when the affordable size is not at least the broker's minimum, the trade is refused here, in
|
| | | //--- the risk-sizing path, before the normalizer can inflate it. (2026-08-11)
|
| | | double minvol = m_symbol.LotsMin();
|
| | | if(minvol > 0.0 && lot < minvol)
|
| | | {
|
| | | PrintFormat("%s: rejected - risk-derived lot %.2f is below the broker minimum %.2f on %s; "
|
| | | "opening at the minimum would exceed the intended risk (budget-capped risk %.2f, "
|
| | | "1-lot loss %.2f)", __FUNCTION__, lot, minvol, m_symbol.Name(), riskAmount, loss);
|
| | | return 0.0;
|
| | | }
|
| | | return lot;
|
2026-07-18 15:53:04 -04:00 | | | }
|
| | | //+------------------------------------------------------------------+
|