forked from mnbvc188199/Warrior_EA
- 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
|