Warrior_EA/Database/TradeJournalManager.mqh
AnimateDread 17270ab308 feat(trade): two books per symbol, and delete the vote exit
Allow_Hedging (default ON, live only on a RETAIL_HEDGING account) gives the EA
an independent long book and short book on its symbol: at most one long and at
most one short, each opened on its own side's vote and each held to its own
barrier. On a netting account, or with the input off, the original
single-position path runs bit-for-bit unchanged and init says which one is live.

WHY THIS INSTEAD OF A VOTE EXIT. The deploy gate certifies
P(label agrees | vote fired) and the label runs to the barrier, so closing early
on a reversal makes the realised outcome stop being the labelled one - the
certified precision no longer describes what is traded. Opening the other side
acts on the new signal and leaves the old position's certification intact, and
costs no more than reversing: both pay the new side's spread, the difference is
only that the existing position runs on to a barrier already measured as
positive-expectancy. So Signal_ThresholdClose is DELETED rather than tuned,
along with its SIGNAL_CLOSE_PRESETS enum; the threshold is pinned to an
arithmetically unreachable 101 (the stock default of 100 is reachable by a
weighted mean of values capped at 100).

Note the two books can never both fill from one signal: CheckOpenLong and
CheckOpenShort test opposite signs of the same m_direction, so at most one clears
per tick. A hedge only forms when a LATER opposite vote fires - which is what
keeps it from being a guaranteed-loss wash pair.

The mechanism is a SelectPosition() override keyed on the active book's magic;
every inherited close/trail path then operates on that book untouched. The long
book keeps Expert_MagicNumber, so no existing position, journal row or
risk-budget state file is re-addressed. Short book is +1.

Four ownership filters had to widen from "== m_magic" to WarriorOwnsMagic(),
or the short book would have been invisible to the code that must reach it:
the scheduled close-all (positions and orders), the risk budget's emergency
flatten, and the journal's MAE/MFE walk. WarriorOwnsMagic() is deliberately NOT
gated on Allow_Hedging - turning the input off while a short-book position is
open would otherwise orphan it with nothing left to close it.

Risk sizing needed no change: CapRiskAmount already subtracts OpenRiskAtStops(),
which counts every position regardless of magic, so the second book is sized
inside what the first one left. Conservative for a hedged pair, which cannot
lose both stops - the safe direction.

Retrain-neutral: neither input is in BuildModelFingerprint() or
ComputeDbConfigFingerprint(). Compiled clean; NOT yet run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:20:35 -04:00

430 lines
22 KiB
MQL5

//+------------------------------------------------------------------+
//| TradeJournalManager.mqh |
//| AnimateDread |
//| https://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "AnimateDread"
#property link "https://www.mql5.com"
#include "DatabaseManager.mqh"
#include "..\Variables\ConfidenceBridge.mqh"
//--- g_riskBudget, fed one result per closed position so the expectancy rule has a sample. Include-
//--- guarded, and this file is pulled in before Money\ and Signals\ pull the same header, so the
//--- single global is defined exactly once wherever the include order lands.
#include "..\Variables\RiskBudget.mqh"
//+------------------------------------------------------------------+
//| One closed trade, as persisted to/read from the TradeJournal |
//| table. Field ORDER matters - DatabaseReadBind()/InsertTradeRecord |
//| match it positionally against the table's own column order |
//| (TradeJournalSchema below), not by name. |
//+------------------------------------------------------------------+
struct STradeJournalRecord
{
long ticket;
int openYear, openMonth, openDay, openDayOfWeek, openHour, openMinute;
int closeYear, closeMonth, closeDay, closeHour, closeMinute;
string symbol;
string direction;
double entryPrice, exitPrice, slPrice, tpPrice, lots;
double profit; // real net P&L: deal profit + swap + commission
double riskDistance; // |entryPrice-slPrice| at open; 0 if no SL was set
double rMultiple; // realized price move / riskDistance; 0 if riskDistance is 0
double maePoints, mfePoints; // worst adverse / best favorable excursion, price units, >=0
double maeR, mfeR; // same, normalized by riskDistance; 0 if riskDistance is 0
double aiConfidence, dbConfidence; // 0..1, snapshotted at entry
string exitReason; // SL/TP/Expert/Manual/StopOut/Other - from the closing deal's DEAL_REASON
string filterID; // which engine was driving trades this run (enabled-NN roster, or "Classic")
};
//--- column order matches STradeJournalRecord's field order exactly (see struct comment above)
const string TradeJournalSchema =
"ticket INTEGER, "
"openYear INTEGER, openMonth INTEGER, openDay INTEGER, openDayOfWeek INTEGER, openHour INTEGER, openMinute INTEGER, "
"closeYear INTEGER, closeMonth INTEGER, closeDay INTEGER, closeHour INTEGER, closeMinute INTEGER, "
"symbol TEXT, direction TEXT, "
"entryPrice REAL, exitPrice REAL, slPrice REAL, tpPrice REAL, lots REAL, "
"profit REAL, riskDistance REAL, rMultiple REAL, "
"maePoints REAL, mfePoints REAL, maeR REAL, mfeR REAL, "
"aiConfidence REAL, dbConfidence REAL, "
"exitReason TEXT, filterID TEXT";
//+------------------------------------------------------------------+
//| In-memory tracking for a still-open position - MAE/MFE can only |
//| be measured live, tick by tick, while the position exists; there |
//| is no post-hoc MQL5 API to recover it once the position is gone. |
//+------------------------------------------------------------------+
struct SJournalOpenTrack
{
ulong ticket;
datetime openTime;
string symbol;
string direction;
double entryPrice, slPrice, tpPrice, lots;
double riskDistance;
double aiConfidence, dbConfidence;
string filterID;
double maePoints;
double mfePoints;
};
//--- per-bucket win-rate/avg-R accumulator, and the fixed set of buckets GenerateReport()'s pipeline
//--- aggregates a closed-trade dump into (hour-of-day/day-of-week/AI-confidence-tier, plus the
//--- overall total and the near-miss/SL-tight running sums) - declared here, ahead of the class, since
//--- CTradeJournalManager's own report-pipeline method signatures (in TradeJournalReport.mqh) take
//--- SJournalStats by reference.
struct SJournalBucket
{
int n;
int wins;
double sumR;
};
void JournalBucketZero(SJournalBucket &b)
{
b.n = 0;
b.wins = 0;
b.sumR = 0.0;
}
//--- win/loss is decided on real profit, not the derived R-multiple (which is forced to 0 whenever
//--- riskDistance is 0 - practically never with this EA's SL modes, but real money P&L is the more
//--- correct signal regardless); rMultiple is only ever used for the magnitude (avg R) stat.
void JournalBucketAdd(SJournalBucket &b, double profit, double rMultiple)
{
b.n++;
if(profit > 0.0)
b.wins++;
b.sumR += rMultiple;
}
double JournalBucketWinRate(const SJournalBucket &b) { return (b.n > 0) ? 100.0 * b.wins / b.n : 0.0; }
double JournalBucketAvgR(const SJournalBucket &b) { return (b.n > 0) ? b.sumR / b.n : 0.0; }
struct SJournalStats
{
SJournalBucket overall;
SJournalBucket perHour[24];
SJournalBucket perDow[7];
//--- confidence buckets: 50-60/60-70/70-80/80-90/90-100 % - only AI-driven trades (aiConfidence>0)
//--- fall into these; classic-signal-only trades leave every bucket untouched, which is correct.
SJournalBucket perConf[5];
int nearMissCount, nonTPCloses;
double slOvershootSum;
int slCount;
int total;
};
void JournalStatsZero(SJournalStats &s)
{
JournalBucketZero(s.overall);
for(int h = 0; h < 24; h++)
JournalBucketZero(s.perHour[h]);
for(int d = 0; d < 7; d++)
JournalBucketZero(s.perDow[d]);
for(int c = 0; c < 5; c++)
JournalBucketZero(s.perConf[c]);
s.nearMissCount = 0;
s.nonTPCloses = 0;
s.slOvershootSum = 0.0;
s.slCount = 0;
s.total = 0;
}
//+------------------------------------------------------------------+
//| Owns the TradeJournal table: detects this EA's own positions |
//| opening/closing (by polling PositionsTotal() every tick rather |
//| than hooking OnTradeTransaction - simpler and robust against |
//| partial fills/multiple deals per position), tracks MAE/MFE live |
//| while a position is open, and resolves the real closing P&L/ |
//| reason from deal history (HistoryDealGetInteger(DEAL_REASON) - |
//| broker-confirmed, not a heuristic) once it closes. |
//+------------------------------------------------------------------+
class CTradeJournalManager
{
private:
CDatabaseManager *m_dbm;
ulong m_magic;
string m_tableName;
SJournalOpenTrack m_tracked[];
int FindTracked(ulong ticket)
{
for(int i = 0; i < ArraySize(m_tracked); i++)
if(m_tracked[i].ticket == ticket)
return i;
return -1;
}
void RemoveTracked(int idx)
{
int last = ArraySize(m_tracked) - 1;
if(idx < 0 || idx > last)
return;
if(idx != last)
m_tracked[idx] = m_tracked[last];
ArrayResize(m_tracked, last);
}
//--- Since 2026-08-19 (per-NN toggles) the run label is the enabled roster, not an enum name:
//--- "MLP+LSTM", "MLP+CONV+LSTM+CONVLSTM+metaGate", "Classic". Legacy rows keep their old
//--- AI_MLP/AI_HYBRID/... labels; the column is a free-text run descriptor, nothing keys off it.
string CurrentFilterID(void)
{
return EnabledNNSummary();
}
string ExitReasonFromDealReason(long reason)
{
switch((ENUM_DEAL_REASON)reason)
{
case DEAL_REASON_SL: return "SL";
case DEAL_REASON_TP: return "TP";
case DEAL_REASON_EXPERT: return "Expert";
case DEAL_REASON_CLIENT:
case DEAL_REASON_MOBILE:
case DEAL_REASON_WEB: return "Manual";
case DEAL_REASON_SO: return "StopOut";
default: return "Other";
}
}
//--- resolves the closing deal for a position no longer in PositionsTotal() - returns false if
//--- history hasn't caught up yet (rare timing edge case); caller keeps tracking it and retries
//--- next tick rather than dropping the trade unrecorded.
//--- sums profit across every OUT/INOUT deal for this position (covers a partial close followed by
//--- a final close, however rare) rather than trusting a single deal to represent the whole
//--- position; exitPrice/exitReason are taken from the LAST (most recent) such deal, representing
//--- how the position ultimately finished.
//--- Money that was at risk on this trade, in account currency: the entry-to-stop distance converted
//--- through the symbol's own tick value, which is what the sizing used in the first place. Returns 0
//--- when the trade carried no stop or the symbol's tick data is unavailable, and the caller then
//--- simply does not score it - a trade with no stop has no R and guessing one would corrupt the mean.
double RiskAmountOf(const SJournalOpenTrack &t)
{
if(t.riskDistance <= 0.0 || t.lots <= 0.0)
return 0.0;
double tickSize = SymbolInfoDouble(t.symbol, SYMBOL_TRADE_TICK_SIZE);
double tickValue = SymbolInfoDouble(t.symbol, SYMBOL_TRADE_TICK_VALUE);
if(tickSize <= 0.0 || tickValue <= 0.0)
return 0.0;
return (t.riskDistance / tickSize) * tickValue * t.lots;
}
bool ResolveClose(ulong ticket, double &exitPrice, double &profit, string &exitReason)
{
if(!HistorySelectByPosition((long)ticket))
return false;
int deals = HistoryDealsTotal();
bool found = false;
profit = 0.0;
for(int d = 0; d < deals; d++)
{
ulong dealTicket = HistoryDealGetTicket(d);
if(dealTicket == 0)
continue;
long entry = HistoryDealGetInteger(dealTicket, DEAL_ENTRY);
if(entry != DEAL_ENTRY_OUT && entry != DEAL_ENTRY_INOUT)
continue;
profit += HistoryDealGetDouble(dealTicket, DEAL_PROFIT) +
HistoryDealGetDouble(dealTicket, DEAL_SWAP) +
HistoryDealGetDouble(dealTicket, DEAL_COMMISSION);
exitPrice = HistoryDealGetDouble(dealTicket, DEAL_PRICE);
exitReason = ExitReasonFromDealReason(HistoryDealGetInteger(dealTicket, DEAL_REASON));
found = true;
}
return found;
}
bool InsertClosedTrade(const SJournalOpenTrack &t, double exitPrice, double profit, string exitReason)
{
MqlDateTime openT, closeT;
TimeToStruct(t.openTime, openT);
TimeToStruct(TimeCurrent(), closeT);
double move = (t.direction == "Buy") ? (exitPrice - t.entryPrice) : (t.entryPrice - exitPrice);
double riskDistance = t.riskDistance;
double rMultiple = (riskDistance > 0.0) ? move / riskDistance : 0.0;
double maeR = (riskDistance > 0.0) ? t.maePoints / riskDistance : 0.0;
double mfeR = (riskDistance > 0.0) ? t.mfePoints / riskDistance : 0.0;
string cols[] = {"ticket", "openYear", "openMonth", "openDay", "openDayOfWeek", "openHour", "openMinute",
"closeYear", "closeMonth", "closeDay", "closeHour", "closeMinute",
"symbol", "direction", "entryPrice", "exitPrice", "slPrice", "tpPrice", "lots",
"profit", "riskDistance", "rMultiple", "maePoints", "mfePoints", "maeR", "mfeR",
"aiConfidence", "dbConfidence", "exitReason", "filterID"
};
string vals[];
ArrayResize(vals, ArraySize(cols));
int i = 0;
vals[i++] = IntegerToString((long)t.ticket);
vals[i++] = IntegerToString(openT.year);
vals[i++] = IntegerToString(openT.mon);
vals[i++] = IntegerToString(openT.day);
vals[i++] = IntegerToString(openT.day_of_week);
vals[i++] = IntegerToString(openT.hour);
vals[i++] = IntegerToString(openT.min);
vals[i++] = IntegerToString(closeT.year);
vals[i++] = IntegerToString(closeT.mon);
vals[i++] = IntegerToString(closeT.day);
vals[i++] = IntegerToString(closeT.hour);
vals[i++] = IntegerToString(closeT.min);
vals[i++] = t.symbol;
vals[i++] = t.direction;
vals[i++] = DoubleToString(t.entryPrice, 8);
vals[i++] = DoubleToString(exitPrice, 8);
vals[i++] = DoubleToString(t.slPrice, 8);
vals[i++] = DoubleToString(t.tpPrice, 8);
vals[i++] = DoubleToString(t.lots, 2);
vals[i++] = DoubleToString(profit, 2);
vals[i++] = DoubleToString(riskDistance, 8);
vals[i++] = DoubleToString(rMultiple, 4);
vals[i++] = DoubleToString(t.maePoints, 8);
vals[i++] = DoubleToString(t.mfePoints, 8);
vals[i++] = DoubleToString(maeR, 4);
vals[i++] = DoubleToString(mfeR, 4);
vals[i++] = DoubleToString(t.aiConfidence, 4);
vals[i++] = DoubleToString(t.dbConfidence, 4);
vals[i++] = exitReason;
vals[i++] = t.filterID;
if(!m_dbm.BeginTransaction())
return false;
bool ok = m_dbm.InsertTradeRecord(m_tableName, cols, vals);
if(!m_dbm.CommitTransaction())
ok = false;
return ok;
}
public:
CTradeJournalManager(void) : m_dbm(NULL), m_magic(0), m_tableName("TradeJournal") {}
bool Init(CDatabaseManager *dbmPtr, ulong magic)
{
m_dbm = dbmPtr;
m_magic = magic;
if(CheckPointer(m_dbm) == POINTER_INVALID)
return false;
//--- CreateTable() needs a genuinely open handle - dbm.Init() only stores the path, it doesn't
//--- open it (OpenDatabase() does that, and Warrior_EA.mq5 doesn't call it until well after
//--- filters/patterns are registered). Opening explicitly here means this also has to run
//--- before AddFilterToSignal() - see the call site in Warrior_EA.mq5's OnInit().
if(!m_dbm.OpenDatabase())
return false;
return m_dbm.CreateTable(m_tableName, TradeJournalSchema);
}
//--- Tracking without a database. The expectancy stop (g_riskBudget.RecordTradeResult) is fed from
//--- this class's close detection, and until 2026-08-11 that feed only existed when
//--- UseDatabaseRanking was on - which ships FALSE, so the da54639 "EV = minus the cost" halt was
//--- armed (ExpectancyMinTrades=40) and never received a single closed trade on a default install.
//--- A risk rule must not be a side effect of an optional analytics toggle: this init gives the
//--- close-detection/MAE-MFE/expectancy path a life of its own, and Update() below simply skips the
//--- DB insert when there is no DB.
void InitTrackingOnly(ulong magic)
{
m_dbm = NULL;
m_magic = magic;
}
//--- call once per tick: detects this EA's own positions opening/closing on the current symbol and
//--- updates MAE/MFE for every one still open. Cheap - PositionsTotal() is typically single digits.
//--- Runs with or without a database (see InitTrackingOnly); only the journal INSERT needs one.
void Update(void)
{
int preExistingCount = ArraySize(m_tracked);
bool seen[];
ArrayResize(seen, preExistingCount);
for(int i = 0; i < preExistingCount; i++)
seen[i] = false;
int total = PositionsTotal();
for(int p = 0; p < total; p++)
{
ulong ticket = PositionGetTicket(p);
if(ticket == 0)
continue;
if(!PositionSelectByTicket(ticket))
continue;
if(PositionGetString(POSITION_SYMBOL) != _Symbol)
continue;
//--- BOTH BOOKS - a short-book position needs its MAE/MFE tracked exactly as a long one does.
if(!WarriorOwnsMagic(PositionGetInteger(POSITION_MAGIC)))
continue;
double currentPrice = PositionGetDouble(POSITION_PRICE_CURRENT);
int idx = FindTracked(ticket);
if(idx < 0)
{
SJournalOpenTrack t;
t.ticket = ticket;
t.openTime = (datetime)PositionGetInteger(POSITION_TIME);
t.symbol = _Symbol;
t.direction = (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) ? "Buy" : "Sell";
t.entryPrice = PositionGetDouble(POSITION_PRICE_OPEN);
t.slPrice = PositionGetDouble(POSITION_SL);
t.tpPrice = PositionGetDouble(POSITION_TP);
t.lots = PositionGetDouble(POSITION_VOLUME);
t.riskDistance = (t.slPrice > 0.0) ? MathAbs(t.entryPrice - t.slPrice) : 0.0;
//--- same-tick snapshot OpenParams() populated right before this trade was sent - see
//--- ConfidenceBridge.mqh's declaration comments.
t.aiConfidence = MathAbs(g_AISignedConfidence);
t.dbConfidence = g_DBConfidence;
t.filterID = CurrentFilterID();
t.maePoints = 0.0;
t.mfePoints = 0.0;
int newIdx = ArraySize(m_tracked);
ArrayResize(m_tracked, newIdx + 1);
m_tracked[newIdx] = t;
}
else
{
if(idx < preExistingCount)
seen[idx] = true;
double excursion = (m_tracked[idx].direction == "Buy") ?
(currentPrice - m_tracked[idx].entryPrice) :
(m_tracked[idx].entryPrice - currentPrice);
if(excursion > m_tracked[idx].mfePoints)
m_tracked[idx].mfePoints = excursion;
if(-excursion > m_tracked[idx].maePoints)
m_tracked[idx].maePoints = -excursion;
}
}
//--- anything tracked before this pass but not seen in it closed since the last tick - resolve
//--- and record it. Walk backwards since RemoveTracked() swap-removes (changes indices >= idx).
for(int i = preExistingCount - 1; i >= 0; i--)
{
if(seen[i])
continue;
double exitPrice = 0.0, profit = 0.0;
string exitReason = "Other";
if(ResolveClose(m_tracked[i].ticket, exitPrice, profit, exitReason))
{
// MAE/MFE and the rest of this closed trade's record cannot be reconstructed after the
// fact once m_tracked[i] is removed below - if the DB insert fails (lock contention on
// the shared COMMON db, disk issue, retry exhaustion), log every field so the record is
// at least manually recoverable from the Experts journal instead of silently vanishing.
//--- FEED THE EXPECTANCY RULE. Reported in R - net profit over the money that was actually
//--- at risk - so results from different symbols, lot sizes and account balances are on one
//--- scale and can share a single mean. riskDistance is the entry-to-stop distance the trade
//--- was sized against, so lots * riskDistance * tickValue-per-point IS the amount at risk;
//--- using the account's own currency conversion via the profit figure keeps it exact rather
//--- than reconstructing tick values here.
//--- `profit` already includes swap and commission (see ResolveClose). That is deliberate and
//--- load-bearing: when the directional edge is zero, cost is the ENTIRE expectancy, so a
//--- gross-profit version of this rule would measure a strategy nobody can actually trade.
double riskAmount = RiskAmountOf(m_tracked[i]);
if(riskAmount > 0.0)
g_riskBudget.RecordTradeResult(profit / riskAmount);
if(CheckPointer(m_dbm) == POINTER_INVALID)
{
//--- tracking-only mode (no UseDatabaseRanking): the expectancy rule above is the
//--- whole point; there is no journal DB to insert into.
RemoveTracked(i);
continue;
}
if(!InsertClosedTrade(m_tracked[i], exitPrice, profit, exitReason))
PrintFormat("%s: ERROR - failed to insert closed trade into journal DB, record LOST from DB (recoverable from this log only): ticket=%I64u direction=%s entry=%.5f exit=%.5f profit=%.2f mae=%.5f mfe=%.5f reason=%s",
__FUNCTION__, m_tracked[i].ticket, m_tracked[i].direction, m_tracked[i].entryPrice, exitPrice, profit,
m_tracked[i].maePoints, m_tracked[i].mfePoints, exitReason);
RemoveTracked(i);
}
//--- else: history hasn't caught up yet this tick - leave it tracked and retry next tick
}
}
//--- forward-declared here, implemented in TradeJournalReport.mqh (kept separate - this file is
//--- the live tracking path, that one is the offline reporting/insights path; no reason for the
//--- per-tick code to pull in report-building logic it never calls).
bool GenerateReport(string &resultPath, string &errorMsg);
private:
//--- GenerateReport()'s four steps, each forward-declared here and implemented in
//--- TradeJournalReport.mqh: fetch the closed-trade rows, derive the tuning suggestions from
//--- already-aggregated stats, then format+write the CSV. (The aggregation step itself,
//--- AggregateJournalStats(), touches no class member and is a free function alongside it.)
bool FetchClosedTrades(STradeJournalRecord &records[], string &errorMsg);
int DeriveSuggestions(const SJournalStats &stats, string &suggestions[]);
bool WriteJournalReportCsv(const SJournalStats &stats, const string &suggestions[], const int sc,
const STradeJournalRecord &records[], string &resultPath, string &errorMsg);
//--- shared by every DeriveSuggestions() suggestion site: appends one already-formatted suggestion
//--- string and advances the count. Forward-declared here, implemented alongside GenerateReport()
//--- in TradeJournalReport.mqh.
void AddSuggestion(string &suggestions[], int &sc, const string text);
};
//--- CTradeJournalManager::GenerateReport() - split out, see that file's own header comment.
#include "TradeJournalReport.mqh"