//+------------------------------------------------------------------+ //| PrgRules.mqh | //| Copyright 2026, Allan NiƱal | //| https://www.allanninal.dev/mql5 | //+------------------------------------------------------------------+ //| Rule engine of Prop Rules Monitor. It only measures: day start, | //| daily and overall loss floors, trading days. It never trades. | //| | //| Daily floor = day-start reference - daily limit | //| reference: balance, equity or the higher of the two at the | //| reset instant (a time of day in a chosen time zone); | //| limit: % of the initial balance or of that reference. | //| Overall floor = initial - max% (static), or the peak (equity, | //| balance, or end-of-day balance) - max% of the initial, which | //| can stop rising once it reaches the initial balance. | //| Breach = equity at or below a floor (open trades count). | //| Times: deals and resets are compared in broker server time; the | //| reset zone is converted with built-in EU/US daylight-saving | //| rules. Nothing here uses the network. | //| MT4 edition: the same measurements. MT4 keeps orders, not deals: | //| the history scan reads balance operations and closed orders (P&L | //| at the close, commission included; a day is a trading day when a | //| trade was opened in it) and is rebuilt when the orders change; | //| the server clock is GMT + an offset learnt from a fresh tick. | //+------------------------------------------------------------------+ // SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 (see LICENSE) #ifndef PRG_RULES_MQH #define PRG_RULES_MQH //--- MT5 shows each enum choice by the comment on its own line, so every member needs one enum ENUM_PRG_REF { PRG_REF_BALANCE=0, // Balance at the reset PRG_REF_EQUITY=1, // Equity at the reset PRG_REF_HIGHER=2 // Higher of balance and equity at the reset }; enum ENUM_PRG_OF { PRG_OF_INITIAL=0, // % of the initial balance (account size) PRG_OF_DAYSTART=1 // % of the day-start reference }; enum ENUM_PRG_ZONE { PRG_ZONE_BROKER=0, // Broker server time PRG_ZONE_PRAGUE=1, // Central Europe (Prague, CET/CEST) PRG_ZONE_UTC=2, // UTC PRG_ZONE_NEWYORK=3, // New York (EST/EDT) PRG_ZONE_LONDON=4, // London (GMT/BST) PRG_ZONE_UTC3=5 // UTC+3 all year }; enum ENUM_PRG_MAX { PRG_MAX_STATIC=0, // Static: initial balance - max % PRG_MAX_TRAIL_EQ=1, // Trailing from the highest equity PRG_MAX_TRAIL_BAL=2, // Trailing from the highest closed balance PRG_MAX_EOD=3, // Trailing from the highest end-of-day balance PRG_MAX_SMART=4 // Two-stage: max % until a gain, then a smaller fixed % }; enum ENUM_PRG_CLOCK { PRG_CLOCK_AUTO=0, // Auto: detect from the live server time PRG_CLOCK_NY=1, // New York close: UTC+2/+3 with US daylight saving PRG_CLOCK_EET=2, // UTC+2/+3 with European daylight saving PRG_CLOCK_FIXED=3 // Fixed offset from UTC, no daylight saving }; //--- one set of rules (the user's own) struct PrgRules { string name; double daily_pct; // 0 = no daily limit ENUM_PRG_REF daily_ref; ENUM_PRG_OF daily_of; ENUM_PRG_ZONE reset_zone; int reset_hour; // hour of the reset in that zone (0-23) double max_pct; // 0 = no overall limit ENUM_PRG_MAX max_mode; bool max_stop_at_initial; // a trailing floor stops rising at the initial balance double smart_gain_pct; // PRG_MAX_SMART: closed-balance gain that switches the floor ... double smart_after_pct; // ... to account size - this % double target_pct; // profit target, 0 = none int min_days; // minimum trading days, 0 = none }; //--- what the engine measured, for the panel struct PrgState { double initial; // account size the % limits use bool initial_auto; // taken from the first deposit datetime day_start; // current reset instant, server time datetime next_reset; double ref_balance; // balance at the reset double ref_equity; // equity at the reset (= balance when not measured) bool ref_measured; // true when the program was running at the reset double daily_floor; // equity at or below this breaks the daily limit (-DBL_MAX = off) double daily_limit; // money double max_floor; // (-DBL_MAX = off) double peak_equity; double peak_balance; double peak_eod; double balance,equity; double today_closed; // closed P&L since the reset int trading_days; // days with at least one new trade double daily_used_pct; // 0..100+ of the daily limit double max_used_pct; }; PrgRules g_rules; PrgState g_st; //--- clock of the broker server, resolved once ENUM_PRG_CLOCK g_prg_clock=PRG_CLOCK_NY; int g_prg_fixed=0; // seconds, PRG_CLOCK_FIXED //+------------------------------------------------------------------+ //| Time zones | //+------------------------------------------------------------------+ //--- Sunday on or after `day` of `month` in year `y`, at `hour` UTC datetime PrgSundayFrom(const int y,const int month,const int day,const int hour) { MqlDateTime t; ZeroMemory(t); t.year=y; t.mon=month; t.day=day; datetime d=StructToTime(t); TimeToStruct(d,t); return(d+(datetime)(((7-t.day_of_week)%7)*86400+hour*3600)); } //--- US daylight saving: second Sunday of March 07:00 UTC to first Sunday of November 06:00 UTC bool PrgUsDst(const datetime utc) { MqlDateTime t; TimeToStruct(utc,t); return(utc>=PrgSundayFrom(t.year,3,8,7) && utc=PrgSundayFrom(t.year,3,25,1) && utc14*3600) // a frozen weekend server time: keep New York close return; int ny=PrgUsDst(gmt) ? 3*3600 : 2*3600; int eu=PrgEuDst(gmt) ? 3*3600 : 2*3600; if(off==ny) return; if(off==eu) { g_prg_clock=PRG_CLOCK_EET; return; } g_prg_clock=PRG_CLOCK_FIXED; g_prg_fixed=(int)off; } string PrgClockName(void) { switch(g_prg_clock) { case PRG_CLOCK_EET: return("UTC+2/+3 EU"); case PRG_CLOCK_FIXED: return(StringFormat("UTC%+.1f",g_prg_fixed/3600.0)); default: return("NY close"); } } //--- floor `t` to the last instant whose time of day is `hour`:00 (all in one clock) datetime PrgFloorToHour(const datetime t,const int hour) { long k=(long)t-hour*3600; long m=k%86400; if(m<0) m+=86400; return((datetime)((long)t-m)); } //--- the reset instant (server time) of the trading day that contains server time `srv` datetime PrgDayStart(const datetime srv) { if(g_rules.reset_zone==PRG_ZONE_BROKER) return(PrgFloorToHour(srv,g_rules.reset_hour)); datetime utc=srv-PrgBrokerOffset(srv); datetime loc=utc+PrgZoneOffset(g_rules.reset_zone,utc); datetime rl=PrgFloorToHour(loc,g_rules.reset_hour); // reset, zone local time datetime ru=rl-PrgZoneOffset(g_rules.reset_zone,rl-PrgZoneOffset(g_rules.reset_zone,utc)); return(ru+PrgBrokerOffset(ru+2*3600)); } string PrgZoneName(const ENUM_PRG_ZONE z) { switch(z) { case PRG_ZONE_PRAGUE: return("Prague"); case PRG_ZONE_UTC: return("UTC"); case PRG_ZONE_NEWYORK: return("New York"); case PRG_ZONE_LONDON: return("London"); case PRG_ZONE_UTC3: return("UTC+3"); default: return("server"); } } //--- current server time: TimeCurrent() in the tester; live, MQL4 has no TimeTradeServer and TimeCurrent stops between //--- ticks (all weekend), so GMT + the server's offset, learnt from a fresh tick (a whole half hour, -12 h .. +14 h) datetime PrgNow(void) { static long off=LONG_MIN; if(IsTesting()) return(TimeCurrent()); long d=(long)TimeCurrent()-(long)TimeGMT(); long r=(long)MathRound(d/1800.0)*1800; if(MathAbs(d-r)<=120 && r>=-12*3600 && r<=14*3600) off=r; return(off==LONG_MIN ? TimeCurrent() : (datetime)((long)TimeGMT()+off)); } //+------------------------------------------------------------------+ //| Persistence: terminal global variables keyed by the login, so a | //| restart, re-attach or timeframe change keeps peaks and the day | //| start: PRG..MON.. | //+------------------------------------------------------------------+ string g_prg_ns=""; // "MON." (set before PrgInit) string PrgKey(const string field) { return("PRG."+IntegerToString(AccountInfoInteger(ACCOUNT_LOGIN))+"."+g_prg_ns+field); } double PrgGet(const string field,const double def) { string k=PrgKey(field); return(GlobalVariableCheck(k) ? GlobalVariableGet(k) : def); } void PrgSet(const string field,const double v) { if(IsTesting() && IsOptimization()) return; GlobalVariableSet(PrgKey(field),v); } //+------------------------------------------------------------------+ //| History scan: initial deposit, closed P&L per trading day, peak | //| balance, peak end-of-day balance, trading days. Called at start | //| and after every new deal; prop accounts have short histories. | //+------------------------------------------------------------------+ double g_day_pnl[]; // closed P&L per trading day ... datetime g_day_key[]; // ... keyed by its reset instant bool g_day_traded[]; // a new position was opened that day double g_initial_input=0; // 0 = first deposit int PrgDaySlot(const datetime key) { int n=ArraySize(g_day_key); if(n>0 && g_day_key[n-1]==key) return(n-1); for(int i=n-1; i>=0; i--) if(g_day_key[i]==key) return(i); ArrayResize(g_day_key,n+1,64); ArrayResize(g_day_pnl,n+1,64); ArrayResize(g_day_traded,n+1,64); g_day_key[n]=key; g_day_pnl[n]=0; g_day_traded[n]=false; return(n); } //--- running totals of the scan, so each call reads only the deals added since the last one double g_scan_run=0,g_scan_peak=0,g_scan_eod=0,g_scan_day_end=0,g_scan_first=0; datetime g_scan_day=0,g_scan_time=0; double g_scan_base=0; // MT4: added to the running sums when no deposit is in the history void PrgScanReset(void) { ArrayResize(g_day_key,0); ArrayResize(g_day_pnl,0); ArrayResize(g_day_traded,0); g_scan_run=g_scan_peak=g_scan_eod=g_scan_day_end=g_scan_first=0; g_scan_day=g_scan_time=0; g_scan_base=0; } //--- the account's history as events in time order (MT4 keeps orders): a balance operation (kind 0), a closed trade's //--- P&L at its close (kind 1, profit + swap + commission), and the opening of a trade (kind 2, value 0), history and //--- open orders; in one second, balance first, then closes, then opens, then reading order int PrgEvents(datetime &t[],int &kind[],double &v[]) { int nh=OrdersHistoryTotal(),no=OrdersTotal(),n=0; ArrayResize(t,2*(nh+no)); ArrayResize(kind,2*(nh+no)); ArrayResize(v,2*(nh+no)); for(int pass=0; pass<2; pass++) for(int i=0; i<(pass==0 ? nh : no); i++) { if(!OrderSelect(i,SELECT_BY_POS,pass==0 ? MODE_HISTORY : MODE_TRADES)) continue; int ty=OrderType(); if(pass==0 && ty==6) // balance operation (deposit, withdrawal); 7 = credit: not balance { t[n]=OrderCloseTime()>0 ? OrderCloseTime() : OrderOpenTime(); kind[n]=0; v[n]=OrderProfit(); n++; continue; } if(ty>OP_SELL) continue; if(StringFind(OrderComment(),"from #")!=0) // the rest of a partly closed order is not a new trade { t[n]=OrderOpenTime(); kind[n]=2; v[n]=0; n++; } if(pass==0) { t[n]=OrderCloseTime(); kind[n]=1; v[n]=OrderProfit()+OrderSwap()+OrderCommission(); n++; } } double key[][2]; ArrayResize(key,n); for(int i=0; i1) ArraySort(key); datetime t2[]; int k2[]; double v2[]; ArrayResize(t2,n); ArrayResize(k2,n); ArrayResize(v2,n); for(int i=0; i0) g_scan_first=v; g_scan_peak=MathMax(g_scan_peak,g_scan_run); g_scan_eod=MathMax(g_scan_eod,g_scan_run); continue; } int sl=PrgDaySlot(key); g_day_pnl[sl]+=v; g_scan_peak=MathMax(g_scan_peak,g_scan_run); } //--- no deposit in the history (the Account History tab starts later, or the tester): the running sums are relative; //--- anchor them to the balance now, so balance peaks and end-of-day balances are real balances if(g_scan_first==0 && n>0) { double base=AccountInfoDouble(ACCOUNT_BALANCE)-g_scan_run; g_scan_run+=base; g_scan_peak+=base; g_scan_eod+=base; g_scan_day_end+=base; g_scan_base=base; } double bal=AccountInfoDouble(ACCOUNT_BALANCE); double first_dep=g_scan_first>0 ? g_scan_first : bal-(g_scan_run-g_scan_base); // tester or no deposit in history g_st.initial_auto=(g_initial_input<=0); g_st.initial=g_initial_input>0 ? g_initial_input : (first_dep>0 ? first_dep : bal); g_st.peak_balance=MathMax(MathMax(g_scan_peak,g_st.initial),bal); //--- end-of-day peak: days that closed before today's reset, plus the stored live value datetime today=PrgDayStart(now); double peak_eod=g_scan_eod; if(g_scan_day!=0 && g_scan_day=start) since+=ev[i]; return(since); } void PrgRebuildDayStart(const datetime start) { g_st.ref_balance=AccountInfoDouble(ACCOUNT_BALANCE)-PrgMovedSince(start); g_st.ref_equity=g_st.ref_balance; g_st.ref_measured=false; } //--- start (or restart) the engine void PrgInit(void) { ZeroMemory(g_st); g_st.day_start=0; PrgScanReset(); PrgScanHistory(); g_st.peak_equity=MathMax(PrgGet("peak_eq",0),MathMax(g_st.peak_balance,AccountInfoDouble(ACCOUNT_EQUITY))); datetime now=PrgNow(); datetime start=PrgDayStart(now); //--- a day start stored by a program that was running at the reset is kept if((datetime)PrgGet("day",0)==start) { g_st.ref_balance=PrgGet("day_bal",0); g_st.ref_equity=PrgGet("day_eq",0); g_st.ref_measured=PrgGet("day_meas",0)>0; } else { PrgRebuildDayStart(start); PrgSet("day",(double)start); PrgSet("day_bal",g_st.ref_balance); PrgSet("day_eq",g_st.ref_equity); PrgSet("day_meas",0); } g_st.day_start=start; g_st.next_reset=PrgDayStart(start+26*3600); if(g_st.next_reset<=start) g_st.next_reset=start+86400; } //+------------------------------------------------------------------+ //| Per tick / timer update. Returns true when a new day started. | //+------------------------------------------------------------------+ bool PrgUpdate(void) { bool new_day=false; datetime now=PrgNow(); g_st.balance=AccountInfoDouble(ACCOUNT_BALANCE); g_st.equity=AccountInfoDouble(ACCOUNT_EQUITY); if(now>=g_st.next_reset) { //--- running through the reset: today's reference is measured, yesterday's close is an end-of-day value; //--- the first tick of the day can come late (a gap) with stops already filled after the reset: those belong //--- to the new day, so they are taken out of both g_st.day_start=PrgDayStart(now); g_st.next_reset=PrgDayStart(g_st.day_start+26*3600); if(g_st.next_reset<=g_st.day_start) g_st.next_reset=g_st.day_start+86400; double since=PrgMovedSince(g_st.day_start); g_st.peak_eod=MathMax(g_st.peak_eod,g_st.balance-since); PrgSet("peak_eod",g_st.peak_eod); g_st.ref_balance=g_st.balance-since; g_st.ref_equity=g_st.equity-since; g_st.ref_measured=true; g_st.today_closed=0; PrgSet("day",(double)g_st.day_start); PrgSet("day_bal",g_st.ref_balance); PrgSet("day_eq",g_st.ref_equity); PrgSet("day_meas",1); PrgTotals(g_st.day_start); new_day=true; } if(g_st.equity>g_st.peak_equity) { g_st.peak_equity=g_st.equity; PrgSet("peak_eq",g_st.peak_equity); } g_st.peak_balance=MathMax(g_st.peak_balance,g_st.balance); //--- daily floor g_st.daily_floor=-DBL_MAX; g_st.daily_limit=0; if(g_rules.daily_pct>0) { double ref=g_st.ref_balance; if(g_rules.daily_ref==PRG_REF_EQUITY) ref=g_st.ref_equity; else if(g_rules.daily_ref==PRG_REF_HIGHER) ref=MathMax(g_st.ref_balance,g_st.ref_equity); g_st.daily_limit=g_rules.daily_pct/100.0*(g_rules.daily_of==PRG_OF_INITIAL ? g_st.initial : ref); g_st.daily_floor=ref-g_st.daily_limit; g_st.daily_used_pct=g_st.daily_limit>0 ? 100.0*(ref-g_st.equity)/g_st.daily_limit : 0; } //--- overall floor g_st.max_floor=-DBL_MAX; if(g_rules.max_pct>0) { double amount=g_rules.max_pct/100.0*g_st.initial; double peak=g_st.initial; switch(g_rules.max_mode) { case PRG_MAX_TRAIL_EQ: peak=g_st.peak_equity; break; case PRG_MAX_TRAIL_BAL: peak=g_st.peak_balance; break; case PRG_MAX_EOD: peak=g_st.peak_eod; break; default: break; } g_st.max_floor=peak-amount; if(g_rules.max_mode==PRG_MAX_SMART && g_rules.smart_gain_pct>0 && g_st.peak_balance>=g_st.initial*(1.0+g_rules.smart_gain_pct/100.0)) { amount=g_rules.smart_after_pct/100.0*g_st.initial; g_st.max_floor=g_st.initial-amount; } if(g_rules.max_mode!=PRG_MAX_STATIC && g_rules.max_mode!=PRG_MAX_SMART && g_rules.max_stop_at_initial) g_st.max_floor=MathMin(g_st.max_floor,g_st.initial); //--- share of the loss budget used: 0 with the full amount of room, 100 at the floor g_st.max_used_pct=amount>0 ? 100.0*(amount-(g_st.equity-g_st.max_floor))/amount : 0; } return(new_day); } //--- equity at or below the daily floor bool PrgDailyBreached(void) { return(g_st.daily_floor>-DBL_MAX && g_st.equity<=g_st.daily_floor); } bool PrgMaxBreached(void) { return(g_st.max_floor>-DBL_MAX && g_st.equity<=g_st.max_floor); } //--- money left before a floor (DBL_MAX when the rule is off) double PrgDailyRoom(void) { return(g_st.daily_floor>-DBL_MAX ? g_st.equity-g_st.daily_floor : DBL_MAX); } double PrgMaxRoom(void) { return(g_st.max_floor>-DBL_MAX ? g_st.equity-g_st.max_floor : DBL_MAX); } //--- the smaller of the two rooms double PrgRoom(void) { return(MathMin(PrgDailyRoom(),PrgMaxRoom())); } #endif //+------------------------------------------------------------------+