//+------------------------------------------------------------------+ //| PooledGate.mqh | //| Cross-instrument certification for the deploy decision | //| Measured 2026-08-17 on SP500 H4: the OOS window is 4,738 bars | //| and the mean triple-barrier label lifespan is 75.6 bars, so the | //| holdout carries ~63 INDEPENDENT observations (see | //| EffectiveSampleSize). Certifying a 3pp edge at 2 sigma needs | //| ~1,036. The deploy gate was therefore unreachable by arithmetic | //| - not because the models were short, but because the window | //| cannot resolve the question either way. Sixteen times short. | //+------------------------------------------------------------------+ #property strict #ifndef WARRIOR_TRAINING_POOLEDGATE_MQH #define WARRIOR_TRAINING_POOLEDGATE_MQH //--- Records older than this are ignored. A chart that was stopped days ago is not evidence about the //--- model running now, and a stale pool silently certifying a dead configuration is the failure mode //--- this guards. Long enough to survive an overnight run, short enough that yesterday cannot vote. #define POOL_MAX_AGE_HOURS 12 //--- Minimum distinct instruments before the pooled statistic is allowed to gate anything. Below this //--- the pool is not a pool - it is one symbol with extra steps, and SE_CORR would equal SE_INDEP. #define POOL_MIN_INSTRUMENTS 3 //--- Ceiling on peers read, sizing the fixed arrays. Well above the 24-instrument catalog. #define POOL_MAX_RECORDS 64 //--- Bumped whenever the record layout changes. A reader that finds a different version SKIPS the //--- record rather than misreading its columns - the stale-enum trap in a different costume. //--- v2: the targetRR column went with the barrier geometry - the swing label has no structural //--- break-even, so poolability is timeframe + version alone. #define POOL_RECORD_VERSION 2 #define POOL_DIR "Warrior_EA\\Pool" //+------------------------------------------------------------------+ //| One instrument's contribution to the pooled certificate. | //+------------------------------------------------------------------+ struct SPoolRecord { string symbol; int timeframe; double chancePct; // this symbol's own zero-skill rate double winPct; // what the model actually collected double effN; // INDEPENDENT calls, already deflated by label lifespan double lifespanBars; long eraCount; datetime stamp; }; //+------------------------------------------------------------------+ //| CROSS-INSTRUMENT CERTIFICATION. | //| | //| One symbol's holdout cannot resolve the deploy question - see the | //| file header: sixteen times short. The answer is more instruments, | //| not more eras, so each chart publishes its own record and every | //| chart reads the pool. | //| | //| It owns a DIRECTORY OF CSV FILES and nothing else. It does not | //| know what a model is, and the two things it needs - the symbol's | //| own numbers and the ratio they were measured at - arrive as | //| arguments. That is why it takes no data view: a gate over files | //| is not a reader of training data. | //+------------------------------------------------------------------+ class CPooledGate { private: //--- ONE-SHOT warning latch. A pool that cannot be written is a silent loss of the whole //--- mechanism, and a mechanism that declines to act must announce it - but once, not per era. bool m_writeWarned; int ReadPooledEvidence(double &pooledExcessPp, double &seIndep, double &seCorr, string &detail); public: CPooledGate(void) : m_writeWarned(false) { } //--- `id` is passed rather than bound so there is no init-order question about when the identity //--- became available. void Publish(const string id, const SPoolRecord &rec); //--- The pooled verdict across every peer on this timeframe. bool Passes(string &report); }; //+------------------------------------------------------------------+ //| Publish this instance's evidence. Called once per era, after the | //| local gate has computed its own numbers. | //| | //| Writes ONLY this instrument's file. The filename carries symbol | //| and timeframe so two charts can never collide, and a chart that | //| restarts overwrites its own record rather than accumulating a | //| history that would let one instrument vote many times. | //+------------------------------------------------------------------+ void CPooledGate::Publish(const string id, const SPoolRecord &rec) { //--- Nothing measurable to contribute. Writing a placeholder would let a symbol with no evidence //--- dilute the pool's weighting, which is the opposite of what inverse-variance weighting is for. if(rec.chancePct <= 0.0 || rec.chancePct >= 100.0 || rec.winPct < 0.0 || rec.effN < 2.0) return; //--- _Period, not Period(): inside a CExpertBase subclass the bare call resolves to the inherited //--- SETTER bool CExpertBase::Period(ENUM_TIMEFRAMES) rather than the builtin. Same predefined //--- variable AutoTune.mqh uses to build its own per-symbol/timeframe filename. string fn = StringFormat("%s\\%s_%d.csv", POOL_DIR, rec.symbol, rec.timeframe); int h = FileOpen(fn, FILE_COMMON | FILE_WRITE | FILE_CSV | FILE_ANSI | FILE_SHARE_READ | FILE_SHARE_WRITE, ','); if(h == INVALID_HANDLE) { //--- One-shot: a pool that cannot be written is a silent loss of the whole mechanism, and a //--- mechanism that declines to act must announce it (the third quiet no-op this codebase has //--- been bitten by). if(!m_writeWarned) { m_writeWarned = true; Print(id + StringFormat(": WARNING - cannot write the pooled-gate record %s (error %d). This " "instrument will not contribute to cross-instrument certification and " "the pool will be short one member.", fn, GetLastError())); } return; } FileWrite(h, POOL_RECORD_VERSION, rec.symbol, rec.timeframe, rec.chancePct, rec.winPct, rec.effN, rec.lifespanBars, rec.eraCount, (long)TimeCurrent()); FileClose(h); } //+------------------------------------------------------------------+ //| Read every peer's record and combine. Returns the number of | //| instruments that qualified; the pooled figures come back through | //| the out-params. | //| | //| INCLUDES THIS INSTRUMENT: its own file was just written, so the | //| directory scan picks it up like any other and there is no special | //| case to get wrong. | //+------------------------------------------------------------------+ int CPooledGate::ReadPooledEvidence(double &pooledExcessPp, double &seIndep, double &seCorr, string &detail) { pooledExcessPp = 0.0; seIndep = 0.0; seCorr = 0.0; detail = ""; SPoolRecord rec[POOL_MAX_RECORDS]; int count = 0; string found; long searchHandle = FileFindFirst(POOL_DIR + "\\*.csv", found, FILE_COMMON); if(searchHandle == INVALID_HANDLE) return 0; do { if(count >= POOL_MAX_RECORDS) break; int h = FileOpen(POOL_DIR + "\\" + found, FILE_COMMON | FILE_READ | FILE_CSV | FILE_ANSI | FILE_SHARE_READ | FILE_SHARE_WRITE, ','); if(h == INVALID_HANDLE) continue; int ver = (int)FileReadNumber(h); //--- Version mismatch: SKIP, never reinterpret. Reading v2 columns as v1 would produce a //--- plausible-looking record built from the wrong fields, which is worse than one fewer member. if(ver != POOL_RECORD_VERSION) { FileClose(h); continue; } SPoolRecord r; r.symbol = FileReadString(h); r.timeframe = (int)FileReadNumber(h); r.chancePct = FileReadNumber(h); r.winPct = FileReadNumber(h); r.effN = FileReadNumber(h); r.lifespanBars = FileReadNumber(h); r.eraCount = (long)FileReadNumber(h); r.stamp = (datetime)(long)FileReadNumber(h); FileClose(h); //--- POOLABILITY. Symbol deliberately does NOT have to match - that is the entire point - but //--- everything that changes what the numbers MEAN does. if(r.timeframe != (int)_Period) continue; // different bar semantics if(r.effN < 2.0 || r.chancePct <= 0.0 || r.chancePct >= 100.0) continue; // no usable estimate if((TimeCurrent() - r.stamp) > POOL_MAX_AGE_HOURS * 3600) continue; // stale: a stopped chart is not evidence about now rec[count++] = r; } while(FileFindNext(searchHandle, found)); FileFindClose(searchHandle); if(count <= 0) return 0; //--- FIXED-EFFECTS COMBINATION. Each symbol is scored against ITS OWN chance rate, so symbols with //--- different geometries and different drifts are directly comparable in this one currency. double sumInvVar = 0.0; for(int i = 0; i < count; i++) { double var = BinomialVar(rec[i].chancePct / 100.0, rec[i].effN); // in fraction^2 if(var <= 0.0) continue; sumInvVar += 1.0 / var; } if(sumInvVar <= 0.0) return 0; double weighted = 0.0, sumWSd = 0.0; for(int i = 0; i < count; i++) { double var = BinomialVar(rec[i].chancePct / 100.0, rec[i].effN); if(var <= 0.0) continue; double w = (1.0 / var) / sumInvVar; weighted += w * (rec[i].winPct - rec[i].chancePct); // percentage points //--- Perfectly-correlated bound: the weighted sum of the individual standard deviations, which //--- is what the pooled SE degenerates to when every member moves together. sumWSd += w * MathSqrt(var) * 100.0; detail += StringFormat("%s%s %.0f%%/%.0f%% n%.0f w%.2f", (detail == "" ? "" : " "), rec[i].symbol, rec[i].winPct, rec[i].chancePct, rec[i].effN, w); } pooledExcessPp = weighted; seIndep = 100.0 * MathSqrt(1.0 / sumInvVar); seCorr = sumWSd; return count; } //+------------------------------------------------------------------+ //| The cross-instrument verdict, as one log line and one boolean. | //| | //| Gates on the PESSIMISTIC bound. A pooled edge that clears SE_CORR | //| cannot be explained by the members being correlated, because that | //| bound already assumes they are perfectly correlated. | //+------------------------------------------------------------------+ bool CPooledGate::Passes(string &report) { report = ""; double excess = 0.0, seIndep = 0.0, seCorr = 0.0; string detail = ""; int members = ReadPooledEvidence(excess, seIndep, seCorr, detail); if(members < POOL_MIN_INSTRUMENTS) { report = StringFormat(" | POOL %d/%d instruments - not enough to certify across symbols yet" " (run more charts on this timeframe; each one is worth far more" " independent evidence than more bars of the same symbol)", members, POOL_MIN_INSTRUMENTS); return false; } double bar = EDGE_MIN_SIGMAS * seCorr; bool pass = (excess > bar); //--- The credit the pool WOULD earn if its members were independent. Printed because the gap between //--- the two bounds is the whole cost of refusing to assume independence, and it is the number that //--- says whether adding a genuinely uncorrelated instrument is worth more than another correlated one. double credit = (seIndep > 0.0) ? seCorr / seIndep : 1.0; report = StringFormat(" | POOLED CERTIFICATE across %d instruments: excess %+.2fpp vs a %.2fpp bar" " (%.0f x SE %.2fpp, the ALL-CORRELATED bound; SE would be %.2fpp if the" " instruments were independent, a %.1fx diversification credit this gate" " deliberately declines to claim) -> %s [%s]", members, excess, bar, EDGE_MIN_SIGMAS, seCorr, seIndep, credit, (pass ? "PASSES" : "fails"), detail); return pass; } //+------------------------------------------------------------------+ #endif // WARRIOR_TRAINING_POOLEDGATE_MQH