preview
Building a Prop-Firm Rule Guard in MQL5 (Part 1): The Observation Layer

Building a Prop-Firm Rule Guard in MQL5 (Part 1): The Observation Layer

MetaTrader 5 — Trading |
217 0
Bruno Nunes Myrrha Ribeiro
Bruno Nunes Myrrha Ribeiro

Contents


Introduction

An Expert Advisor that trades a funded or challenge account works under two rulebooks at once. The first one is its own strategy logic. The second one belongs to the proprietary trading firm, and the EA usually knows nothing about it. Break the first rulebook and you lose a trade. Break the second one and you lose the account, usually while the terminal is doing exactly what you told it to do.

MetaTrader 5 offers nothing for that second rulebook. There is no account-level constraint object, no prop-day calendar, and no notion of a challenge that started with a fixed initial balance. The daily limit resets at an hour the broker's clock does not match. The overall floor may sit still, or it may ratchet up behind the account and then freeze there for good.

In practice, the guard is written by hand and converges on a common shape: input thresholds, an OnTick() if-block, a close-all helper, and one per-protection boolean to prevent re-firing. That shape is where the interesting part of this article lives, because it fails in specific ways that stay invisible while the account is healthy.

These failures are not arithmetic; they are structural. They occur when rules observe each other's state, when a value has multiple writers, or when a request and its confirmation share the same code path. Fixing them one comparison at a time produces a guard that fails in a new way the following month.

  • C1: a threshold defaults to 0.0 and the test is greater-or-equal, so enabling a protection without typing a number satisfies it on the very first tick.
  • C2: a reset of the monitoring flags sits inside the trigger loop and clears the flags of triggers that the same pass has not evaluated yet.
  • C3: the Algo Trading toggle is fire-and-forget, so a cascade can switch trading off and then straight back on within one pass.
  • C4: the overall drawdown recomputes its reference from the current balance whenever it fires, so the floor walks down with the account.
  • C5: the daily rollover never rearms the intraday flags, so one firing disarms the daily protection for the rest of the run.
  • C6: an excursion measurement reuses the drop-from-peak formula and routes the result to a profit action, so a pure loss triggers a profit response.
  • C7: the check runs immediately after an asynchronous action, reads the value from before it, and concludes the action worked.
  • C8: the close-all routine ignores every retcode and reports the account protected, so a requote or a closed market leaves positions open.
  • C9: nothing is persisted, so a reboot of the virtual server refills the consumed drawdown budget.

Three of those nine (C3, C7, and C8) belong to the acting half and are built in Part 2. The other six are structural; where a fix here also needs the persisted state or the facade from Part 2, the text says so.

Prop-firm rule guard — architecture

Observation layer of the guard

There are 23 headers under Include/PropRulebook/, grouped by role and read top to bottom in dependency order. Arrows show the aggregate flow, not every include.

Much of this ground has been walked on this site already, and the reader may recognize it from earlier articles. The three articles below overlap with the observation layer directly, and each one solves a narrower version of the same problem. None of them assembles the whole set of firm rules into a single frozen view of the account.

What this piece adds is the account arithmetic those leave out, plus the idea that a finding and an action are two different things. The record every rule reads here is a plain struct, filled once per pass and passed to every rule as a const reference. The type is not immutable, but no rule can change it during a pass, and that is what makes a pass reproducible.

This is the first of two articles. Part 1 covers the observation layer (records, a rule contract, and snapshot builders). Part 2 covers the decision layer (rules, two-phase resolution, actuators, persisted state, and the facade).


What the rulebook actually asks for

Before any class exists, the requirements have to be pinned down, and prop-firm rules are less uniform than they first look. Three distinctions shape the observation layer directly. Each one changes what has to be measured rather than what has to be compared, so each one lands in a record or in a clock instead of landing in a rule.

Maximum drawdown comes in several mechanics. The static variant is a fixed amount below the starting balance and never moves for the life of the account. The trailing variant starts at the same distance but ratchets up with the account peak and never descends, either on intraday equity or on the end-of-day balance, and some programs freeze that floor at the starting balance. Hybrid rules exist too. Guarding a trailing account with static calculations is not a rounding error. It is the wrong floor entirely.

The daily reset hour is not midnight. It varies per firm, and it is expressed in the firm's own time zone rather than the broker server's. A loss booked at 16:55 server time and one booked at 17:05 may or may not belong to the same prop day, depending entirely on two numbers the trader has to supply.

Some rules block a payout, not the account. The consistency rule divides the best single day's profit by total profit, and a violation withholds the withdrawal until the distribution rebalances. The same holds for the minimum trading days requirement. A guard that answers either of those by closing positions is actively harming the trader.


Freezing the account into one record

The first design decision stops one rule from changing what the next one reads, and everything else depends on it. If a rule reads live account state, then a rule that fires and closes positions changes what the next rule reads. Evaluate eight rules in a loop, let the third one flatten the account, and rules four through eight look at a different account than rules one through three. This does not fix C2 by itself: C2 is a reset inside the trigger loop, and the order of the pass closes it by rearming every rule before any of them is evaluated.

So rules do not read the account at all. They read a record that is built once per pass and handed to every rule as a const reference. The file Include/PropRulebook/PropAccountSnapshot.mqh declares it. Apart from Clear(), the struct has no method that changes anything, no threshold, and no comparison. It is a value type, cleared and refilled once per pass. Its fields are public, so read-only comes from the const reference, not from the type.

struct PropAccountSnapshot
  {
   //--- pass identity
   ulong             pass_id;                 // monotonic counter, 1 for the first pass
   datetime          server_time;             // TimeCurrent() at the moment of the build

   //--- account figures (each terminal function called exactly once)
   double            balance;
   double            equity;
   double            credit;
   double            floating_pl;             // equity - balance - credit
   double            margin;
   double            free_margin;
   double            margin_level;            // percent, 0.0 when margin is 0
   double            initial_balance;         // challenge anchor supplied by the facade
   //--- prop-firm calendar (from CPropSessionClock)
   int               prop_day_index;          // integer day number under the firm's reset hour
   datetime          prop_day_start;          // server time of the current prop day's boundary
   bool              is_new_prop_day;         // true only on the first pass of a new prop day
   bool              past_weekend_cutoff;     // pre-weekend flat cutoff already reached
   int               minutes_to_weekend_cutoff;
   // ...
};

The record carries five more groups: the opening balance and realized result of the current prop day, the ledger aggregates the payout rules need, the excursion measurements, the exposure figures, and the calendar proximity fields. Everything a rule is allowed to know is in there, and all of it is read in one pass before the first rule runs. That is a sequence of AccountInfoDouble() calls and one sweep of the positions, not an atomic read, but every rule sees the same values.

   //--- convenience readers used by several rules
   bool              HasExposure()      const { return positions_total > 0; }
   double            DayLoss()          const { return day_start_balance - equity; }   // positive while losing
   //--- the mirror of DayLoss: positive while the prop day is up
   double            DayProfit()        const { return equity - day_start_balance; }
   //--- profit since the challenge started, measured from the fixed anchor
   double            ChallengeProfit()  const { return equity - initial_balance; }

Note the field initial_balance. The snapshot does not capture the challenge anchor; it receives it. The contract around the record is enforced by convention rather than by the compiler, so a rule must never call the account or the calendar API on its own. The verification script asserts that every verdict of a pass carries the same timestamp, which shows the reports are consistent, not that a rule stayed away from the terminal.

The second decision is the other half of the same fix. If a rule can act, then evaluating rules and taking action are the same phase, and phase separation is impossible. So a rule returns a verdict and nothing else. The file Include/PropRulebook/PropRuleVerdict.mqh defines three enumerations before the record itself, and two of them are deliberately independent axes.

enum ENUM_PROP_SEVERITY
  {
   PROP_SEV_OK       = 0,   // within limits
   PROP_SEV_INFO     = 1,   // informational progress report
   PROP_SEV_WARNING  = 2,   // inside the safety buffer, not breached
   PROP_SEV_ADVISORY = 3,   // a payout-blocking condition, account is safe
   PROP_SEV_BREACH   = 4    // the firm's limit itself is violated
  };
enum ENUM_PROP_DIRECTIVE
  {
   PROP_DIRECTIVE_NONE              = 0,   // nothing to do
   PROP_DIRECTIVE_ADVISORY          = 1,   // log/notify only, never touches the account
   PROP_DIRECTIVE_BLOCK_NEW_ENTRIES = 2,   // CanOpen() must return false
   PROP_DIRECTIVE_FLATTEN           = 3,   // close relevant exposure, keep trading enabled
   PROP_DIRECTIVE_LOCKDOWN          = 4    // close exposure AND disable Algo Trading
  };

Keeping severity and directive apart is what lets the consistency rule report PROP_SEV_ADVISORY, because something is genuinely wrong, while asking for PROP_DIRECTIVE_ADVISORY, which touches nothing at all. With a single axis, reporting a payout rule as serious means picking a value that also says act hard, and only extra mapping keeps it from closing positions. Two axes do not guarantee the right action either: the coordinator in Part 2 has to act on the directive alone.

struct PropRuleVerdict
  {
   ENUM_PROP_RULE      rule;
   bool                evaluated;   // false when the rule is not registered at all
   bool                abstained;   // rule registered but had no usable input this pass
   bool                latched;     // rule is holding a breach latch from an earlier pass
   ENUM_PROP_SEVERITY  severity;
   ENUM_PROP_DIRECTIVE directive;
   double              measured;    // what the rule observed
   double              threshold;   // the limit it was compared against
   double              headroom;    // distance still available before breach
   double              projected;   // forward-looking figure, rule specific (see CConsistencyRule)
   string              unit;        // "ccy", "share", "days", "minutes"
   string              message;
   datetime            stamp;
   // ...
};

The fields evaluated and abstained are worth separating. A rule that was never registered reports the first one; a rule that ran with nothing usable to work with reports the second. Both differ from a clean pass, and collapsing them into one boolean is how a guard gives a false all-clear while its data source is down.

The third record is the configuration, and C1 dies here in a single sentence: enablement is a flag and never a side effect of a value. The file Include/PropRulebook/PropFirmProfile.mqh pairs every threshold with its own bool. A profile straight out of Clear() has every flag false and every threshold zero, and it registers exactly zero rules.

   //--- static maximum drawdown (C4)
   bool              use_static_dd;
   double            static_dd_amount;         // account currency, measured from initial_balance

   //--- trailing maximum drawdown
   bool              use_trailing_dd;
   double            trailing_dd_amount;       // account currency below the running peak
   ENUM_PROP_TRAIL_MODE trailing_mode;
   bool              trailing_lock_enabled;    // freeze the floor once the lock level is reached
   double            trailing_lock_level;      // balance level at which the floor freezes;
   // 0 means initial_balance + trailing_dd_amount

A flag alone is not sufficient, though. Someone will tick the static drawdown switch and leave the amount at zero. Validate() catches that before the first tick and names the field that has to be fixed. A disabled rule with a zero threshold passes without comment, because that zero is legal precisely when nothing will ever read it.

      if(use_static_dd && static_dd_amount <= 0.0)
        { err = "static_dd_amount is enabled but unset (0.0)"; return false; }

      if(use_trailing_dd && trailing_dd_amount <= 0.0)
        { err = "trailing_dd_amount is enabled but unset (0.0)"; return false; }

      if(use_daily_loss && daily_loss_amount <= 0.0)
        { err = "daily_loss_amount is enabled but unset (0.0)"; return false; }
      if(use_consistency && (consistency_max_share <= 0.0 || consistency_max_share >= 1.0))
        { err = "consistency_max_share is enabled but not a share in (0,1)"; return false; }


One contract for every rule

Eight evaluators with eight ad-hoc signatures is eight places to get the life cycle wrong. The file Include/PropRulebook/IPropRule.mqh reduces them to four operations plus serialization, and the two reset operations being separate is the fix for C5. Nothing outside the registry ever knows which concrete rule type it is holding.

   //--- Reads the rule's own slice of the profile. MUST return false
   //--- (with a human-readable err) when the rule is switched on but
   //--- its threshold is unset -- never "fall back to a default", the
   //--- silent-default habit is exactly what caused C1.
   virtual bool      Configure(const PropFirmProfile &profile, string &err) = 0;

   //--- One pass. Read snap; write out and the rule's own fields.
   //--- Forbidden: calling any terminal state function, touching
   //--- another rule, triggering any reset.
   virtual void      Evaluate(const PropAccountSnapshot &snap, PropRuleVerdict &out) = 0;

   //--- Full challenge reset. Clears account-level latches and
   //--- re-anchors whatever the rule anchors. Reachable only from
   //--- CPropRulebook::ResetChallenge() and the restore-rollback
   //--- branch inside Init() -- never from a routine trigger.
   virtual void      Reset(const PropAccountSnapshot &snap) = 0;

   //--- Prop-day boundary. Rearms intraday latches and rebases
   //--- per-day baselines. MUST NOT clear an account-level latch and
   //--- MUST NOT move a challenge anchor or a trailing lock (C4/C5).
   virtual void      ResetIntraday(const PropAccountSnapshot &snap) = 0;

Reset() means a brand new challenge: clear the account-level latches and re-anchor. ResetIntraday() means a new prop day: rearm the intraday latches, rebase the per-day baselines, and touch nothing that belongs to the challenge. A single combined routine has to pick one of those meanings, and either pick is wrong half the time.

The base class carries the bookkeeping every concrete rule would otherwise duplicate. Report() fills the identity, the stamp, and the unit fields in one place, and Abstain() is its sibling for the registered-but-blind case. One shared helper then gives every rule the same early warning without each one inventing its own.

   //--- helper every concrete rule uses to fill its verdict, so the
   //--- unit/rule/stamp bookkeeping is written in exactly one place
   void              Report(PropRuleVerdict &out,
               const PropAccountSnapshot &snap,
               const ENUM_PROP_SEVERITY severity,
               const ENUM_PROP_DIRECTIVE directive,
               const double measured,
               const double threshold,
               const double headroom,
               const string unit,
               const string message,
               const bool latched = false)
     {
      out.Clear();
      out.rule      = m_id;
      out.evaluated = true;
      out.abstained = false;
      out.latched   = latched;
      out.severity  = severity;
      out.directive = directive;
      out.measured  = measured;
      out.threshold = threshold;
      out.headroom  = headroom;
      out.unit      = unit;
      out.message   = message;
      out.stamp     = snap.server_time;
      m_last        = out;
     }
   //--- WARNING once the consumed share of a limit passes the buffer
   ENUM_PROP_SEVERITY BufferSeverity(const double consumed, const double limit) const
     {
      if(limit <= 0.0)
         return PROP_SEV_OK;
      if(consumed >= limit * m_buffer_ratio)
         return PROP_SEV_WARNING;
      return PROP_SEV_OK;
     }

With the safety buffer ratio at its default of 0.80, a rule starts warning once four-fifths of its allowance is gone. Serialization is part of the contract rather than an afterthought, because C9 is not fixable by bolting persistence onto the outside of a rule table. Each rule knows which part of its own state matters.

   //--- Restart survival (C9). Serialize() returns a single line with
   //--- no '=' and no newline; Deserialize() must reject anything it
   //--- does not fully understand by returning false, so the store can
   //--- fall back to a fresh anchor instead of loading partial data.
   virtual string    Serialize()  const              { return ""; }
   //--- the default: accept an empty payload and keep the current state
   virtual bool      Deserialize(const string state) { return (StringLen(state) == 0); }


Where a day begins and what it earned

Include/PropRulebook/CPropSessionClock.mqh is the only component that maps timestamps to calendar boundaries. Everything else receives the answer precomputed in the snapshot. The time model is worth stating explicitly, because getting the sign of the offset wrong silently misattributes a whole day of profit and loss.

firm_local_time = server_time + timezone_offset_hours * 3600

The reset hour is expressed in firm-local time, so the boundary lands on the server hour that is the reset hour minus the offset. A reset at 15:00 with an offset of minus 2 puts the boundary at 17:00 server time. The conversion below is the whole of it, and every other date question in the package reduces to this one.

   //--- Server time at which the prop day containing t began.
   datetime          PropDayStart(const datetime t) const
     {
      long offset_secs = (long)m_offset_hours * 3600;
      long local       = (long)t + offset_secs;

      MqlDateTime lt;
      TimeToStruct((datetime)local, lt);

      long sod        = (long)lt.hour * 3600 + (long)lt.min * 60 + (long)lt.sec;
      long midnight   = local - sod;
      long boundary   = midnight + (long)m_reset_hour * 3600 + (long)m_reset_minute * 60;

      if(local < boundary)
         boundary -= 86400;

      return (datetime)(boundary - offset_secs);
     }
   //--- Stateful rollover detector. Returns true exactly once per prop
   //--- day: on the first call whose timestamp belongs to a day index
   //--- different from the last one seen. A clock with no day index
   //--- yet only records it and returns false -- starting the EA is
   //--- not a rollover. A restored index is compared like any other,
   //--- so a restart on a later prop day rolls over exactly once.
   bool              DetectRollover(const datetime t)
     {
      int idx = PropDayIndex(t);

      if(!m_has_day_index)
        {
         m_last_day_index = idx;
         m_has_day_index  = true;
         return false;
        }

      if(idx == m_last_day_index)
         return false;

      m_last_day_index = idx;
      return true;
     }

The first call after configuration returns false. Starting the EA is not a rollover, and if it were treated as one, every terminal restart would rearm the intraday latches the guard had just consumed. That is why the day index belongs in the serialized state: a machine that reboots at 03:00 must not treat 03:01 as a new prop day. A restored index also works the other way: if the terminal was off across the rollover, the first pass after the restart returns true once and the daily protections rearm.

The weekend cutoff is deliberately not converted through the offset. A broker's Friday close is a server-clock event, so the cutoff day, hour, and minute are read as plain server time and compared through a seconds-of-week key. Midnight is a legal cutoff hour, so zero cannot mean unconfigured, and the profile supplies a sentinel instead.

   //--- Pre-weekend flat cutoff, in server time.
   bool              IsPastWeekendCutoff(const datetime t) const
     {
      if(!m_use_weekend_cutoff || m_cut_hour == PROP_UNSET_HOUR)
         return false;
      return (WeekKey(t) >= CutoffKey());
     }

The consistency rule needs to know which single prop day produced the most profit. The minimum-trading-days rule needs to know how many days carried a qualifying trade. Neither question can be answered from the account's current state, because both of them need history, and that history has to follow the firm's calendar rather than the server's.

The file Include/PropRulebook/CDailyProfitLedger.mqh keeps one record per day in a bounded array and resolves a timestamp to a record through the clock. Deals arrive one at a time, and the aggregation per day is deliberately small: a realized sum, a volume sum, a deal count, and the largest single volume seen on that day.

   //--- Books one executed deal. net_profit already includes swap and
   //--- commission; volume is the deal volume in lots and is used for
   //--- the qualifying-day test only (an entry deal carries volume but
   //--- no profit, an exit deal carries both).
   bool              RecordDeal(const datetime deal_time, const double net_profit, const double volume)
     {
      if(!m_ready)
         return false;

      int pos = Resolve(deal_time);
      if(pos < 0)
         return false;

      m_days[pos].realized     += net_profit;
      m_days[pos].total_volume += volume;
      m_days[pos].deal_count++;

      if(volume > m_days[pos].largest_volume)
         m_days[pos].largest_volume = volume;

      return true;
     }

The field largest_volume rather than total_volume is what the qualifying-day test reads, and the two are not interchangeable. The rule as modeled here counts a day only when one trade reaches the minimum size, so four trades of 0.005 lots do not make one qualifying 0.01-lot trade. Firms that count differently need a different test.

   //--- Days carrying at least one trade of min_volume lots or more.
   //--- A day with only a 0.005-lot trade against a 0.01 minimum does
   //--- not count, which is exactly how the firms count it.
   int               QualifyingDays(const double min_volume) const
     {
      int n = 0;
      for(int i = 0; i < ArraySize(m_days); i++)
         if(m_days[i].largest_volume >= min_volume)
            n++;
      return n;
     }

The best-day share has an arithmetic trap in it. With a negative total, the statement that the best day is 300% of total profit is a true division and a meaningless answer. So the ledger returns zero, and the consistency rule never reads it as a pass: with no positive realized profit, it abstains.

   //--- best single day / total profit, as a 0..1 share. Returns 0
   //--- when total profit is not positive, because "the best day is
   //--- 300% of a negative total" is arithmetic, not a rule breach.
   double            BestDayShare() const
     {
      double total = TotalRealized();
      if(total <= 0.0)
         return 0.0;
      return BestDayProfit() / total;
     }

The forward-looking method is the interesting one, and it exists for a reason that only becomes clear in Part 2. It answers what the share would become if a given profit were realized right now, on the day that contains a given timestamp. The snapshot builder calls it with the current floating result, and what that buys is a guard able to refuse a profit which would break a payout rule.

   //--- P5. The share the account WOULD show if extra_profit were
   //--- realized on the prop day containing 'when' right now.
   //--- CPropSnapshotBuilder calls this with the current floating P/L
   //--- while assembling the frozen snapshot; the coordinator only
   //--- reads the resulting verdict before allowing a profit-target
   //--- flatten.
   double            ProjectedBestDayShare(const datetime when, const double extra_profit) const
     {
      if(m_clock == NULL)
         return 0.0;

      int today_idx = m_clock.PropDayIndex(when);
      int pos       = Find(today_idx);

      double total = 0.0;
      double best  = 0.0;

      for(int i = 0; i < ArraySize(m_days); i++)
        {
         double value = m_days[i].realized;
         if(i == pos)
            value += extra_profit;
         total += value;
         if(value > best)
            best = value;
        }

      //--- today has no record yet: the projected day is a new one
      if(pos < 0)
        {
         total += extra_profit;
         if(extra_profit > best)
            best = extra_profit;
        }

      if(total <= 0.0)
         return 0.0;
      return best / total;
     }


Measuring without judging

The excursion tracker is the structural answer to C6, and it is the shortest class in the package that carries the most design intent. Maximum favorable and maximum adverse excursion are useful numbers. They are also, arithmetically, drop-from-peak and rise-from-trough, which are the same two formulas a drawdown rule uses.

The failure mode was never a wrong formula. It was a correct formula wired to the wrong action, and labeling a peak drop as profit is only possible when something is in a position to label it at all. So the file Include/PropRulebook/CExcursionTracker.mqh does not derive from the rule interface. It has no threshold, no enable flag, and no verdict.

   //--- called once per pass by the snapshot builder, with the same
   //--- balance/equity pair every rule of that pass will read
   void              Update(const double balance, const double equity)
     {
      if(!m_seeded)
         return;

      if(balance > m_peak_balance)
         m_peak_balance   = balance;
      if(balance < m_trough_balance)
         m_trough_balance = balance;
      if(equity  > m_peak_equity)
         m_peak_equity    = equity;
      if(equity  < m_trough_equity)
         m_trough_equity  = equity;
     }
   //--- MFE: the best the account ever was, above the anchor. Never negative.
   double            MfeBalance() const { return MathMax(0.0, m_peak_balance - m_anchor); }
   //--- best excursion above the anchor, clamped so it never reads negative
   double            MfeEquity()  const { return MathMax(0.0, m_peak_equity  - m_anchor); }

   //--- MAE: the worst the account ever was, below the anchor, reported
   //--- as a positive magnitude. Never negative.
   double            MaeBalance() const { return MathMax(0.0, m_anchor - m_trough_balance); }
   //--- worst excursion below the anchor, clamped the same way
   double            MaeEquity()  const { return MathMax(0.0, m_anchor - m_trough_equity); }

The clamping at zero is the last mile of the same idea. On an account that only ever lost money, the favorable excursion is zero rather than a negative number that some comparison might read as a profit. When a bug comes from two concepts sharing a formula, the durable fix is to leave one of them no way to reach a decision.

MQL5 ships a native economic calendar, and it is unavailable more often than you would like. A feed that returns no events on failure silently switches the news protection off, and a feed that assumes an event blocks every entry forever. So the file Include/PropRulebook/CEconomicCalendarFeed.mqh carries a third state, and it probes for that state exactly once.

   //--- One-off availability probe, a heuristic: a live calendar is
   //--- expected to hold events in any two-week window, so an empty
   //--- wide window (or any runtime error) reads as "no calendar".
   void              Probe(const datetime now)
     {
      m_probed = true;

      MqlCalendarValue probe[];
      ResetLastError();
      int count = CalendarValueHistory(probe, now - 7 * 86400, now + 7 * 86400, NULL, NULL);
      m_last_error = GetLastError();

      if(count > 0)
        {
         m_available = true;
         return;
        }

      m_available = false;
      PrintFormat("CEconomicCalendarFeed: native calendar unavailable (values=%d, error=%d) -- "
                  "the news rule will abstain instead of blocking.", count, m_last_error);
     }

The probe treats an empty fourteen-day window as no calendar. That is a heuristic: two quiet weeks are unlikely but possible, and then the news rule abstains instead of reporting a clean pass. The refresh path then throttles the real reads, filters to high importance, and resolves each event's currency through its country. The feed also has a synthetic mode, and that mode is what makes the news rule verifiable at all.

//--- Test / tester mode: the feed stops calling the terminal and
//--- serves whatever AddSyntheticEvent() was given. Availability
//--- becomes true only once at least one synthetic event exists,
//--- so an empty synthetic feed still abstains rather than
//--- pretending the calendar is clean.
void              EnableSyntheticMode(const bool on)
  {
   m_synthetic = on;
   if(on)
     {
      ArrayFree(m_events);
      m_available = false;
     }
   else
     {
      m_probed    = false;
      m_available = false;
     }
  }


Assembling the observation

Include/PropRulebook/CPropSnapshotBuilder.mqh is the snapshot's only writer and enforces a single account read per pass. Everything the five observation classes produce lands in one record here, and the record then goes out as a constant reference for the rest of the pass.

   //--- Builds the pass's single observation.
   //---
   //--- anchor is the challenge initial balance owned and persisted by
   //--- the facade -- the builder never captures it itself, because a
   //--- builder that re-captured the anchor on restart would refill
   //--- the consumed drawdown budget (C4/C9).
   bool              Build(const datetime now, const double anchor, PropAccountSnapshot &snap)
     {
      if(!m_ready)
         return false;

      snap.Clear();

      m_pass_id++;
      snap.pass_id     = m_pass_id;
      snap.server_time = now;

      //--- account: read once, here, and nowhere else in the pass
      snap.balance         = AccountInfoDouble(ACCOUNT_BALANCE);
      snap.equity          = AccountInfoDouble(ACCOUNT_EQUITY);
      snap.credit          = AccountInfoDouble(ACCOUNT_CREDIT);
      snap.margin          = AccountInfoDouble(ACCOUNT_MARGIN);
      snap.free_margin     = AccountInfoDouble(ACCOUNT_MARGIN_FREE);
      snap.margin_level    = AccountInfoDouble(ACCOUNT_MARGIN_LEVEL);
      snap.floating_pl     = snap.equity - snap.balance - snap.credit;
      snap.initial_balance = anchor;
      // ...

Look at the last line of that block. The anchor arrives as a parameter. The builder has every opportunity to capture it from the current balance and no permission to do so, because a builder that re-captured the anchor on restart would refill the consumed drawdown budget. That is the builder's half of C4 and C9; keeping the anchor across a restart is the persisted state's half, in Part 2.

      //--- day baseline: captured on the day's first pass, then held so
      //--- the daily loss rule measures against a fixed reference all day
      if(!m_day_seeded || snap.is_new_prop_day || m_day_index != snap.prop_day_index)
        {
         m_day_start_balance = snap.balance;
         m_day_start_equity  = snap.equity;
         m_day_index         = snap.prop_day_index;
         m_day_seeded        = true;
        }
      snap.day_start_balance = m_day_start_balance;
      snap.day_start_equity  = m_day_start_equity;

One Evaluate() pass — the non-cascading model

Snapshot fields resolved on one tick

The flow is: verify what is outstanding, freeze one observation, rearm on a new prop day, evaluate every rule in isolation,

then resolve to at most one directive and dispatch it. The rearm step closes C2 and C5 here; C7, C3, and C8 belong to the verification and dispatch steps of Part 2.

The day baseline is captured on the first pass after the boundary and then held, so the daily loss rule measures against a fixed reference for the whole day instead of a moving one. Exposure is a single sweep, and it is the sweep that every rule's position count and open volume come from later in the pass.

      int total = PositionsTotal();
      for(int i = total - 1; i >= 0; i--)
        {
         ulong ticket = PositionGetTicket(i);
         if(ticket == 0)
            continue;
         if(!PositionSelectByTicket(ticket))
            continue;
         if(m_profile.restrict_to_magic &&
            (ulong)PositionGetInteger(POSITION_MAGIC) != m_profile.magic)
            continue;

         datetime opened = (datetime)PositionGetInteger(POSITION_TIME);
         count++;
         volume += PositionGetDouble(POSITION_VOLUME);

         if(oldest == 0 || opened < oldest)
            oldest = opened;
         if(newest == 0 || opened > newest)
            newest = opened;
        }

The oldest and newest open times are collected here because the news exemption test needs an open time and is forbidden from asking the terminal for one. The builder's own persisted state is small but load-bearing, and its restore path contains one guard that is very easy to leave out.

      //--- a seeded day whose opening balance is 0 would make the daily loss
      //--- rule measure "0 - equity", i.e. a permanent profit, and silently
      //--- disarm it. Reject the record instead and let the facade re-seed.
      if(seeded && day_balance <= 0.0)
         return false;

A restored record that would disable a protection is worse than no record at all. Rejecting it costs one re-seeded baseline; accepting it costs a daily loss rule that never fires again. That asymmetry is the rule of thumb for the whole persistence layer, and Part 2 applies it to the rest of the saved state.


Limitations

No tick, no pass. The snapshot is built when the consuming EA asks for it. Over a weekend, during a broker outage, or on a symbol that stops ticking, nothing is observed and therefore nothing is enforced. A gap that opens beyond the floor is already beyond the floor when the first tick after it finally arrives. So the weekend cutoff, the news blackout, and the drawdown floors are not independent loops; they run only when the EA runs a pass.

The ledger only knows what it was told. Deals reach it through the trade transaction handler or through a direct injection call. There is no history backfill at startup, so deals executed while the terminal was closed, or before the guard was ever attached, stay invisible to the consistency share and to the qualifying-day count.

The day baseline is captured at the first pass of the day, not at the boundary. If the terminal is offline when the prop day rolls over, the opening balance is captured whenever the first pass after reconnection runs. Any result realized in between is attributed to the new day's opening balance rather than measured as part of the day's loss.

The native calendar does not work in the Strategy Tester, and the ledger is a bounded ring. A backtest therefore exercises the feed's unavailable path rather than its data path. The ring defaults to 120 prop days, so a longer challenge computes its consistency share and its qualifying-day count over a moving window rather than over the full history.

Several firm and broker policies are fixed in this model. They are known limits, not settings:

  • The time zone offset is in whole hours: no daylight saving, no half-hour or 45-minute zones, and no broker offset that moves on its own.
  • The daily loss is day_start_balance - equity. Measuring it from starting equity, closed results, or the intraday equity low needs another helper.
  • Credit is always excluded (floating_pl = equity - balance - credit), with no setting to count it.
  • Drawdown covers a static floor on equity and a trailing floor. A static floor on the closed balance, or a hybrid rule, needs its own rule class.
  • A qualifying day needs one trade of the minimum size. Counting total volume, trade count, or holding time needs another test.
  • Synthetic mode cannot model a calendar that is available but empty; the closest test puts one event outside the blackout window.
  • Nothing in the language stops a rule from calling the terminal or calendar API, so a new rule can break the contract without a compile error.


Conclusion

Part 1 removed several failure modes by changing shape rather than by adding checks. Freeze the account into one record per pass and rules can no longer disturb each other. Make a rule report a verdict instead of taking an action and evaluation separates cleanly from resolution. Pair every threshold with its own flag and reject any profile that fails Validate(), and an unconfigured protection cannot fire.

What exists at this point is an observation layer: three records, one rule contract, and five classes that resolve a session boundary, a per-day ledger, an excursion measurement, a calendar that admits when it is blind, and one builder that writes the snapshot. Part 2 turns that observation into a decision, and then into an action that verifies itself.

The files below are attached individually and also together in MQL5.zip, which already carries the folder structure the terminal expects. The package is the complete library, so it also holds the concrete rules and the decision, action, persistence, and facade files (CRuleCoordinator, CProtectiveActionExecutor, CTerminalAlgoSwitch, CGuardStateStore, CPropRulebook), attached so that the demo and the test compile. Part 2 covers them.

# Name Type Description
1 MQL5/Experts/PropRulebook/PropRulebookDemo.mq5 Expert Advisor Minimal integration demo: loads a preset, runs one evaluation pass per tick, gates a simulated entry behind CanOpen() and feeds executed deals into the daily ledger.
2 MQL5/Include/PropRulebook/CConsistencyRule.mqh Include Weighs the best single day's profit as a share of total profit against the payout consistency threshold.
3 MQL5/Include/PropRulebook/CDailyLossRule.mqh Include Weighs the loss accumulated since the configured daily reset boundary against the daily loss limit.
4 MQL5/Include/PropRulebook/CDailyProfitLedger.mqh Include Records realized profit and qualifying-trade facts per prop day, and answers the aggregate questions the rules ask.
5 MQL5/Include/PropRulebook/CEconomicCalendarFeed.mqh Include Reads high-impact events from the native economic calendar into a plain array, and reports when it cannot see them.
6 MQL5/Include/PropRulebook/CExcursionTracker.mqh Include Measures maximum favorable and adverse excursion of balance and equity against the challenge anchor, and tracks the running peak.
7 MQL5/Include/PropRulebook/CGuardStateStore.mqh Include Persists and restores the guard's state across restarts through a checksummed, atomically replaced terminal file.
8 MQL5/Include/PropRulebook/CMinTradingDaysRule.mqh Include Counts the days carrying a trade of at least the qualifying size against the minimum-trading-days requirement.
9 MQL5/Include/PropRulebook/CNewsWindowRule.mqh Include Decides whether the current moment falls inside the blackout window around a high-impact economic event.
10 MQL5/Include/PropRulebook/CProfitTargetRule.mqh Include Tracks progress toward the profit target and can ask for a flatten once that level is reached.
11 MQL5/Include/PropRulebook/CPropRulebook.mqh Include Public facade of the package: wires the session clock, ledger, calendar feed, excursion tracker, snapshot builder, rule table, coordinator and executor.
12 MQL5/Include/PropRulebook/CPropSessionClock.mqh Include Translates broker-server time into calendar boundaries: which day a moment falls in, when it started, and when it resets.
13 MQL5/Include/PropRulebook/CPropSnapshotBuilder.mqh Include Assembles the one frozen snapshot that every rule of a single evaluation pass reads.
14 MQL5/Include/PropRulebook/CProtectiveActionExecutor.mqh Include Closes positions and removes pending orders with per-request retcode inspection, bounded retries and an explicit verification pass.
15 MQL5/Include/PropRulebook/CRuleCoordinator.mqh Include Resolves the verdicts of one pass into at most one terminal directive, with no rule able to mutate another's outcome.
16 MQL5/Include/PropRulebook/CRuleRegistry.mqh Include Builds and owns the active rule table, refusing to register any rule whose parameters are absent or otherwise unconfigured.
17 MQL5/Include/PropRulebook/CStaticDrawdownRule.mqh Include Weighs equity against a floor anchored permanently to the initial balance captured at challenge start.
18 MQL5/Include/PropRulebook/CTerminalAlgoSwitch.mqh Include Drives the terminal's global Algo Trading button through a pending latch that verifies the requested state on later passes.
19 MQL5/Include/PropRulebook/CTrailingDrawdownRule.mqh Include Weighs equity against a floor that ratchets up with the account peak and then freezes.
20 MQL5/Include/PropRulebook/CWeekendFlatRule.mqh Include Asks for a flatten only when the pre-weekend cutoff has been reached while exposure is still open.
21 MQL5/Include/PropRulebook/IPropRule.mqh Include Abstract base class holding the one contract every rule implements: configure or refuse, evaluate a frozen snapshot, report a verdict.
22 MQL5/Include/PropRulebook/PropAccountSnapshot.mqh Include Immutable per-pass record holding every account, session, excursion and calendar figure a rule is allowed to read.
23 MQL5/Include/PropRulebook/PropFirmProfile.mqh Include Configuration record in which every threshold is paired with its own explicit enable flag.
24 MQL5/Include/PropRulebook/PropRuleVerdict.mqh Include Verdict record plus the rule-id, severity and directive enumerations one rule reports for one pass.
25 MQL5/Scripts/PropRulebook/TestPropRulebook.mq5 Script Synthetic-data verification script: every figure is built in the file, the rules are exercised through their real public API, and the outcome is written to the Journal and to a JSON file.
26 MQL5.zip Archive Archive with all files that can be unpacked into the terminal installation directory, and all files will be located in the required places.
Optimal Trade Exit in MQL5: Longstaff-Schwartz Least-Squares Monte Carlo for Continuation-Value Estimation Optimal Trade Exit in MQL5: Longstaff-Schwartz Least-Squares Monte Carlo for Continuation-Value Estimation
This article models position exits as an American option and implements the Longstaff–Schwartz least‑squares Monte Carlo in native MQL5. The engine calibrates drift and volatility from recent returns, simulates forward price paths, and uses backward induction to compare immediate exercise to continuation. It integrates into an EA with hard time and price backstops and a live indicator, and is tested on four symbol/timeframe combinations with mixed, reproducible Strategy Tester results.
Neural Networks in Trading: Iterative Forecast Refinement Models (Conclusion) Neural Networks in Trading: Iterative Forecast Refinement Models (Conclusion)
Introducing the RAFT framework — a powerful tool for analyzing and forecasting financial time series. Its flexible and optimized architecture ensures accurate forecasts, stable performance, and faster data processing. RAFT reduces the risk of errors and makes it easier to create effective trading strategies.
Building A Candlestick Trend Constraint Model (Part 12) Measuring Lower-Timeframe Trigger Quality Building A Candlestick Trend Constraint Model (Part 12) Measuring Lower-Timeframe Trigger Quality
This article extends the A‑B‑C‑D constraint model with a measurement layer. We capture confirmation timing, breakout beyond Candle C, and post‑signal MFE/MAE; aggregate results by A‑B relationship; and write one CSV row per completed D window while showing concise CCanvas comments. The separation of detection, replay, visualization, measurement, and optional execution provides clean, analyzable data for further research.
Zero-Copy Tick Streaming (Part 2) : Reading MetaTrader 5 Ticks into Python and Benchmarking the Result Zero-Copy Tick Streaming (Part 2) : Reading MetaTrader 5 Ticks into Python and Benchmarking the Result
We build a Python reader that attaches to the MetaTrader 5 shared memory segment, reproduces the Arrow columnar layout, and constructs ArrowArray/ArrowSchema via ctypes so pyarrow imports ticks without copying. The code honors the three-way schema contract and the seqlock generation counter to read consistent batches. A standalone benchmark verifies performance against a legacy per-field struct.unpack path with real numbers.