forked from animatedread/Warrior_EA
309 lines
15 KiB
MQL5
309 lines
15 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"
|
|
//+------------------------------------------------------------------+
|
|
//| 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 (AIType name, 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;
|
|
};
|
|
//+------------------------------------------------------------------+
|
|
//| 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);
|
|
}
|
|
string CurrentFilterID(void)
|
|
{
|
|
if(AIType == AI_NONE)
|
|
return "Classic";
|
|
return EnumToString((AI_CHOICE)AIType);
|
|
}
|
|
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.
|
|
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);
|
|
}
|
|
//--- 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.
|
|
void Update(void)
|
|
{
|
|
if(CheckPointer(m_dbm) == POINTER_INVALID)
|
|
return;
|
|
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;
|
|
if((ulong)PositionGetInteger(POSITION_MAGIC) != m_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.
|
|
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);
|
|
};
|
|
//--- CTradeJournalManager::GenerateReport() - split out, see that file's own header comment.
|
|
#include "TradeJournalReport.mqh"
|