- Replaced standard library signal modules with custom implementations to allow for named patterns and improved voting. - Added new input parameters for module weights, allowing for optimization of individual signal contributions. - Enhanced the management of trades with new options for breakeven and management cut. - Introduced a mechanism for dynamic ranking of signal weights based on historical performance. - Improved initialization logic to ensure proper registration of filters and handling of trading conditions. - Added detailed logging for trading permissions and account status during initialization.
315 lines
15 KiB
MQL5
315 lines
15 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| 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 <Math\Alglib\alglib.mqh>
|
|
|
|
#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
|