//+------------------------------------------------------------------+ //| WarriorNet.mqh | //| AnimateDread | //| | //| A NEURAL NETWORK THAT TRAINS IN THE TERMINAL, using the ALGLIB | //| that ships with MT5. No DLL, no OpenCL, no Python, no export step.| //| | //| The deleted stack was ~15,000 lines to do this: a hand-rolled | //| CNet, three compute backends, an era/epoch scheduler, a deploy | //| gate and a .nnw format. MLPCreateC1 + MLPTrainES + MLPSerialize | //| are the same four operations, already written and already tested. | //| | //| THE FEATURE NAMES ARE THE CONTRACT, and this is the one idea from | //| the old stack worth keeping verbatim (System\NNFilter.mqh): the | //| file records the name of every input column in the order it was | //| trained on, and Load() REFUSES a model whose names do not match | //| what the caller is about to feed it. A network handed a different | //| column is not degraded, it is reading noise under a familiar name | //| - and it will keep producing confident numbers while it does. | //| | //| EARLY STOPPING BY CONSTRUCTION. MLPTrainES takes a TRAINING and a | //| VALIDATION matrix as separate arguments, so a holdout is not | //| something the caller may forget - the API will not run without | //| one. The split here is CHRONOLOGICAL: the validation rows are the | //| LAST rows, never a random sample, because a random split of | //| overlapping market windows leaks the answer across the boundary. | //+------------------------------------------------------------------+ #ifndef WARRIOR_SIMPLE_NET_MQH #define WARRIOR_SIMPLE_NET_MQH #include #define WARRIOR_NET_MAGIC "WARRIOR_NET 1" #define WARRIOR_NET_HIDDEN 24 // CAP on the hidden layer - the actual width is derived // from the sample size in Train(); see the note there. #define WARRIOR_NET_DECAY 0.001 // weight decay - ALGLIB's regularisation term #define WARRIOR_NET_RESTART 3 // random restarts, best kept #define WARRIOR_NET_VAL_FRAC 0.30 // last 30% of rows are the validation set //--- HOW DEEP A TRAINING SWEEP MAY REACH, in bars. A caller passes min(this, Bars()) to //--- CWarriorSignal::DeepenPrices(), so it is a ceiling on ambition, never a promise of history. //--- It exists because the standard library's own ceiling is 1024 (Series.mqh:11) and that number //--- was silently sizing both the training set and, through it, the hidden layer derived below. //--- 8192 covers ~32 years of D1 and ~5 of H1 - past which the oldest rows describe a market //--- structure the newest ones no longer share, and the walk-forward retrain is the better answer. #define WARRIOR_NET_HISTORY 8192 class CWarriorNet { private: CMultilayerPerceptronShell m_net; bool m_loaded; double m_auc; // held-out AUC of the last fit; <0 when never trained int m_nIn; string m_names[]; // the column contract, in order string m_why; //--- Names joined with single spaces - the form written to and compared from the file. string JoinNames(const string &names[]) const { string s = ""; for(int i = 0; i < ArraySize(names); i++) s += (i ? " " : "") + names[i]; return s; } public: CWarriorNet(void) : m_loaded(false), m_auc(-1.0), m_nIn(0), m_why("not loaded") {} ~CWarriorNet(void) {} bool Loaded(void) const { return m_loaded; } string Why(void) const { return m_why; } int Inputs(void) const { return m_nIn; } double AUC(void) const { return m_auc; } //--- TRAIN on `rows` samples of `nIn` features plus a 0/1 label in the last column. //--- `xy` must already be in CHRONOLOGICAL order: the split below takes the tail as validation. //--- `embargo` = rows dropped from the END of the training half, so that no training sample //--- overlaps in time with the first validation sample. Zero means the split is a bare cut. bool Train(CMatrixDouble &xy, const int rows, const int nIn, const string &names[], const int embargo = 0); bool Save(const string path, const string &names[], const string note); bool Load(const string path, const string &names[]); //--- P(label == 1) for one raw feature vector, or -1.0 when no model is loaded. double Score(double &x[]); }; //+------------------------------------------------------------------+ bool CWarriorNet::Train(CMatrixDouble &xy, const int rows, const int nIn, const string &names[], const int embargo) { m_loaded = false; if(nIn <= 0 || ArraySize(names) != nIn) { m_why = StringFormat("feature count %d does not match %d name(s)", nIn, ArraySize(names)); return false; } //--- THE NET SIZES ITSELF TO THE DATA, rather than the data being required to fit the net. //--- //--- Ten samples per weight is the usual rule of thumb, and a net fitted below it memorises its //--- training set - the in-sample error then looks excellent and means nothing. A FIXED width //--- turns that rule into a wall: 18 inputs x 24 hidden is 480 weights, needing 4,800 rows, and //--- eleven years of DAILY bars yields about 900. The honest response to a small sample is a //--- small model, not a lower standard, so the width is derived from the row count and the rule //--- is preserved instead of relaxed. //--- //--- weights = nIn*hidden + hidden*2 <= rows/10 -> hidden <= rows / (10*(nIn+2)) int hidden = (int)MathFloor((double)rows / (10.0 * (nIn + 2))); if(hidden > WARRIOR_NET_HIDDEN) hidden = WARRIOR_NET_HIDDEN; // never wider than the cap, however much data exists if(hidden < 2) { //--- Even two hidden units would be over-fitted. Refusing is the answer: a model trained here //--- would vote confidently on noise, and a module that abstains is visibly doing nothing //--- whereas one that has memorised its sample looks like it is working. m_why = StringFormat("%d row(s) cannot support even a 2-unit hidden layer at 10 rows/weight" " (%d inputs needs >= %d rows) - not trained", rows, nIn, 20 * (nIn + 2)); return false; } const int weights = nIn * hidden + hidden * 2; //--- CHRONOLOGICAL SPLIT. The tail is the validation set. A random split would put a row's //--- neighbours on both sides of the boundary, and overlapping windows make those near-copies. const int valRows = (int)MathMax(1, MathRound(rows * WARRIOR_NET_VAL_FRAC)); //--- THE EMBARGO. A chronological cut is not enough when each row is built from a WINDOW: two //--- rows from adjacent bars share almost their whole path, so the last training rows and the //--- first validation rows are the same trades one bar apart, and the "held-out" tail has seen //--- its own answers. Measured 2026-09-11 on the management net: every first fit reported AUC //--- 0.66-0.77 on 4 months of overlapping virtual trades, and every deeper refit converged to //--- ~0.51 on three symbols. Dropping `embargo` rows before the boundary - one horizon's worth - //--- removes the overlap, at the cost of a slightly smaller training half. const int gap = (embargo > 0 && embargo < rows / 4) ? embargo : 0; const int trnRows = rows - valRows - gap; if(trnRows <= nIn + 1) { m_why = "training half too small after the split"; return false; } CMatrixDouble trn(trnRows, nIn + 1), val(valRows, nIn + 1); for(int r = 0; r < trnRows; r++) for(int c = 0; c <= nIn; c++) trn.Set(r, c, xy.Get(r, c)); for(int r = 0; r < valRows; r++) for(int c = 0; c <= nIn; c++) val.Set(r, c, xy.Get(trnRows + gap + r, c)); // validation starts AFTER the gap //--- Classifier: two outputs, softmax, so Score() reads a calibrated-ish probability rather than //--- a regression value that has to be squashed by hand. CAlglib::MLPCreateC1(nIn, hidden, 2, m_net); int info = 0; CMLPReportShell rep; CAlglib::MLPTrainES(m_net, trn, trnRows, val, valRows, WARRIOR_NET_DECAY, WARRIOR_NET_RESTART, info, rep); if(info <= 0) { m_why = StringFormat("MLPTrainES refused the dataset (info %d)", info); return false; } m_nIn = nIn; ArrayResize(m_names, nIn); for(int i = 0; i < nIn; i++) m_names[i] = names[i]; m_loaded = true; m_why = "trained"; //--- DID IT LEARN ANYTHING? Reported separately from whether it MADE MONEY, because those are //--- different questions and this project has only ever answered the second. A net can be //--- worthless and still sit inside a profitable run, or genuinely informative and lose money //--- because the exits are wrong - and P&L cannot tell those apart. AUC on the held-out tail //--- can, and it costs one pass over rows the fit never saw. //--- //--- AUC, not accuracy alone: with an unbalanced label, "always say no" scores well on accuracy //--- and learns nothing. The base rate is printed beside it so that trap is visible rather than //--- inferred. AUC 0.50 is a coin flip; below 0.50 means the model is reliably WRONG, which is //--- information too and usually a sign the label is inverted somewhere. double pv[]; int lv[]; ArrayResize(pv, valRows); ArrayResize(lv, valRows); double xrow[], yrow[]; ArrayResize(xrow, nIn); int pos = 0, hit = 0; for(int r = 0; r < valRows; r++) { for(int c = 0; c < nIn; c++) xrow[c] = val.Get(r, c); CAlglib::MLPProcess(m_net, xrow, yrow); pv[r] = (ArraySize(yrow) > 1) ? yrow[1] : 0.0; lv[r] = (int)MathRound(val.Get(r, nIn)); if(lv[r] == 1) pos++; if((pv[r] >= 0.5 ? 1 : 0) == lv[r]) hit++; } double auc = 0.5; const int neg = valRows - pos; if(pos > 0 && neg > 0) { //--- Rank-sum (Mann-Whitney) form: no sorting of pairs, no O(n^2) sweep over 1,000+ rows. int idx[]; ArrayResize(idx, valRows); for(int i = 0; i < valRows; i++) idx[i] = i; for(int i = 1; i < valRows; i++) // insertion sort by score, ascending { const int k = idx[i]; int j = i - 1; while(j >= 0 && pv[idx[j]] > pv[k]) { idx[j + 1] = idx[j]; j--; } idx[j + 1] = k; } double rankSum = 0.0; int i2 = 0; while(i2 < valRows) // average ranks inside a tie group { int j2 = i2; while(j2 + 1 < valRows && pv[idx[j2 + 1]] == pv[idx[i2]]) j2++; const double avgRank = 0.5 * ((i2 + 1) + (j2 + 1)); for(int k2 = i2; k2 <= j2; k2++) if(lv[idx[k2]] == 1) rankSum += avgRank; i2 = j2 + 1; } auc = (rankSum - 0.5 * pos * (pos + 1.0)) / ((double)pos * neg); } PrintFormat("CWarriorNet: trained on %d row(s) (%d train / %d embargo / %d validation, chronological)," " %d input(s), %d hidden, %d weight(s). ALGLIB info %d.", rows, trnRows, gap, valRows, nIn, hidden, weights, info); PrintFormat("CWarriorNet: OUT-OF-SAMPLE AUC %.3f, accuracy %.1f%% against a %.1f%% base rate" " (%d positive of %d validation rows).", auc, 100.0 * hit / valRows, 100.0 * pos / valRows, pos, valRows); m_auc = auc; return true; } //+------------------------------------------------------------------+ bool CWarriorNet::Save(const string path, const string &names[], const string note) { if(!m_loaded) return false; string model = ""; CAlglib::MLPSerialize(m_net, model); //--- FILE_COMMON so the tester and the live terminal read the same file. const int h = FileOpen(path, FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON); if(h == INVALID_HANDLE) { m_why = StringFormat("cannot write %s (error %d)", path, GetLastError()); return false; } FileWriteString(h, WARRIOR_NET_MAGIC + "\n"); FileWriteString(h, StringFormat("inputs %d\n", m_nIn)); FileWriteString(h, "features " + JoinNames(names) + "\n"); FileWriteString(h, "note " + note + "\n"); FileWriteString(h, model); FileClose(h); PrintFormat("CWarriorNet: wrote %s (%d input(s)).", path, m_nIn); return true; } //+------------------------------------------------------------------+ bool CWarriorNet::Load(const string path, const string &names[]) { m_loaded = false; const int h = FileOpen(path, FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON); if(h == INVALID_HANDLE) { m_why = "no model file at " + path; return false; } const string magic = FileReadString(h); const string inputs = FileReadString(h); const string feats = FileReadString(h); const string note = FileReadString(h); string model = ""; while(!FileIsEnding(h)) model += FileReadString(h) + "\n"; FileClose(h); if(StringFind(magic, WARRIOR_NET_MAGIC) != 0) { m_why = "bad magic in " + path; return false; } //--- 🛑 THE REFUSAL THAT MATTERS. A model whose columns differ from what the caller builds is not //--- slightly wrong, it is reading a different quantity under the same name - and it will keep //--- returning confident probabilities while doing so. Refusing is the only safe answer, and it //--- must be LOUD, because the alternative failure is completely silent. const string want = "features " + JoinNames(names); if(feats != want) { m_why = "feature contract mismatch"; PrintFormat("CWarriorNet: REFUSED %s - the model was trained on different columns.\n" " file : %s\n build: %s\n" " Retrain, or fix the feature builder; do NOT run this model.", path, feats, want); return false; } CAlglib::MLPUnserialize(model, m_net); m_nIn = (int)StringToInteger(StringSubstr(inputs, StringLen("inputs "))); ArrayResize(m_names, ArraySize(names)); for(int i = 0; i < ArraySize(names); i++) m_names[i] = names[i]; m_loaded = true; m_why = "loaded"; PrintFormat("CWarriorNet: loaded %s - %d input(s). %s", path, m_nIn, note); return true; } //+------------------------------------------------------------------+ double CWarriorNet::Score(double &x[]) { if(!m_loaded || ArraySize(x) != m_nIn) return -1.0; double y[]; CAlglib::MLPProcess(m_net, x, y); if(ArraySize(y) < 2) return -1.0; return y[1]; // softmax P(class 1) = "this one pays" } #endif // WARRIOR_SIMPLE_NET_MQH