2026-08-12 19:54:12 +07:00
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| CHistoryTracker.mqh |
|
|
|
|
|
//| Centaur Quant Architecture — Execution Module |
|
|
|
|
|
//| Trade_Closed Feedback-Loop Emitter |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| PURPOSE |
|
|
|
|
|
//| Detects positions opened by this EA (symbol + magic) that have |
|
|
|
|
|
//| since closed, recovers the original AI score and initial SL from |
|
|
|
|
|
//| the order comment ("CEN:<score>:<sl>"), and computes the final |
|
|
|
|
|
//| net profit + R-multiple. The composition root turns each record |
|
|
|
|
|
//| into an SDP Trade_Closed payload via CSDPEncoder. |
|
|
|
|
|
//| State memory: every closed ticket is emitted exactly once. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
#property strict
|
|
|
|
|
|
|
|
|
|
#ifndef HISTORYTRACKER_MQH
|
|
|
|
|
#define HISTORYTRACKER_MQH
|
|
|
|
|
|
|
|
|
|
//--- structured closed-trade record consumed by the composition root ---
|
|
|
|
|
struct SClosedTrade
|
|
|
|
|
{
|
|
|
|
|
bool valid; // true when the record is complete
|
|
|
|
|
ulong ticket; // position/order ticket
|
|
|
|
|
double profit; // net PnL (profit + swap + commission)
|
|
|
|
|
double r_multiple; // net PnL / initial risk amount
|
|
|
|
|
double initial_ai_score; // original AI score from the comment
|
|
|
|
|
datetime close_time; // closing deal time (server)
|
|
|
|
|
string symbol; // instrument
|
2026-08-16 11:12:59 +07:00
|
|
|
//--- Gate B evidence (additive observability; Evidence Protocol v0.1 §4) ---
|
|
|
|
|
double entry_price; // actual fill price (opening deal)
|
|
|
|
|
double initial_sl; // initial SL recovered from "CEN:<score>:<sl>"
|
|
|
|
|
long position_type; // POSITION_TYPE_BUY / POSITION_TYPE_SELL
|
|
|
|
|
double exit_price; // volume-weighted mean exit price (closing deals)
|
|
|
|
|
double exit_volume; // total closed volume (closing deals)
|
|
|
|
|
ulong exit_deal; // last closing deal ticket
|
|
|
|
|
long deal_reason; // DEAL_REASON of the last closing deal
|
|
|
|
|
double swap; // total swap (closing side)
|
|
|
|
|
double commission; // total commission (closing side)
|
2026-08-12 19:54:12 +07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
class CHistoryTracker
|
|
|
|
|
{
|
|
|
|
|
private:
|
|
|
|
|
string m_symbol; // instrument filter
|
|
|
|
|
long m_magic; // magic filter
|
|
|
|
|
bool m_ready;
|
|
|
|
|
|
|
|
|
|
ulong m_tracked[]; // open tickets from the previous scan
|
|
|
|
|
SClosedTrade m_pending[]; // detected closed trades awaiting delivery
|
|
|
|
|
int m_pending_head;
|
|
|
|
|
enum { COMPACT_THRESHOLD = 32 };
|
|
|
|
|
|
|
|
|
|
void SnapshotPositions(ulong &tickets[]);
|
|
|
|
|
bool Contains(const ulong &list[], const ulong ticket);
|
|
|
|
|
bool BuildClosedRecord(const ulong ticket, SClosedTrade &out);
|
|
|
|
|
bool ParseCenComment(const string comment, double &score, double &initial_sl);
|
|
|
|
|
void Enqueue(const SClosedTrade &rec);
|
|
|
|
|
void CompactQueue();
|
|
|
|
|
|
|
|
|
|
public:
|
|
|
|
|
CHistoryTracker(const string symbol, const long magic);
|
|
|
|
|
~CHistoryTracker();
|
|
|
|
|
|
|
|
|
|
//--- returns true while a newly closed trade is available ---
|
|
|
|
|
bool Check(SClosedTrade &out_trade);
|
|
|
|
|
|
|
|
|
|
//--- read access ---
|
|
|
|
|
bool IsReady() const { return m_ready; }
|
|
|
|
|
string Symbol() const { return m_symbol; }
|
|
|
|
|
int PendingCount() const;
|
|
|
|
|
};
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| Constructor — bind symbol/magic filters. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
CHistoryTracker::CHistoryTracker(const string symbol, const long magic)
|
|
|
|
|
: m_symbol(symbol),
|
|
|
|
|
m_magic(magic),
|
|
|
|
|
m_ready(false),
|
|
|
|
|
m_pending_head(0)
|
|
|
|
|
{
|
|
|
|
|
if(StringLen(m_symbol) == 0)
|
|
|
|
|
{
|
|
|
|
|
PrintFormat("[CHistoryTracker] ERROR: empty symbol.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
m_ready = (SymbolInfoDouble(m_symbol, SYMBOL_POINT) > 0.0);
|
|
|
|
|
if(!m_ready)
|
|
|
|
|
PrintFormat("[CHistoryTracker] ERROR: symbol '%s' not available.", m_symbol);
|
|
|
|
|
else
|
|
|
|
|
PrintFormat("[CHistoryTracker] INFO: ready on %s (magic %I64d).", m_symbol, m_magic);
|
|
|
|
|
}
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| Destructor — nothing to release. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
CHistoryTracker::~CHistoryTracker()
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| Check — deliver one newly closed trade per call (true) until the |
|
|
|
|
|
//| queue is drained (false). First call snapshots the open set; a |
|
|
|
|
|
//| ticket tracked earlier and missing now has been closed. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
bool CHistoryTracker::Check(SClosedTrade &out_trade)
|
|
|
|
|
{
|
|
|
|
|
if(!m_ready)
|
|
|
|
|
return false;
|
|
|
|
|
//--- serve closures detected in a previous scan first ---
|
|
|
|
|
if(m_pending_head < ArraySize(m_pending))
|
|
|
|
|
{
|
|
|
|
|
out_trade = m_pending[m_pending_head++];
|
|
|
|
|
if(m_pending_head >= COMPACT_THRESHOLD)
|
|
|
|
|
CompactQueue();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
//--- snapshot the EA's currently open positions ---
|
|
|
|
|
ulong current[];
|
|
|
|
|
SnapshotPositions(current);
|
|
|
|
|
//--- any previously tracked ticket now missing = closed ---
|
|
|
|
|
for(int i = 0; i < ArraySize(m_tracked); i++)
|
|
|
|
|
{
|
|
|
|
|
const ulong ticket = m_tracked[i];
|
|
|
|
|
if(Contains(current, ticket))
|
|
|
|
|
continue;
|
|
|
|
|
SClosedTrade rec;
|
|
|
|
|
if(BuildClosedRecord(ticket, rec))
|
|
|
|
|
Enqueue(rec);
|
|
|
|
|
}
|
|
|
|
|
//--- advance the tracked set to the live snapshot ---
|
|
|
|
|
ArrayResize(m_tracked, ArraySize(current));
|
|
|
|
|
for(int i = 0; i < ArraySize(current); i++)
|
|
|
|
|
m_tracked[i] = current[i];
|
|
|
|
|
if(m_pending_head < ArraySize(m_pending))
|
|
|
|
|
{
|
|
|
|
|
out_trade = m_pending[m_pending_head++];
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| SnapshotPositions — tickets of open positions matching filters. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
void CHistoryTracker::SnapshotPositions(ulong &tickets[])
|
|
|
|
|
{
|
|
|
|
|
ArrayResize(tickets, 0);
|
|
|
|
|
for(int i = PositionsTotal() - 1; i >= 0; i--)
|
|
|
|
|
{
|
|
|
|
|
const ulong t = PositionGetTicket(i);
|
|
|
|
|
if(t == 0 || !PositionSelectByTicket(t))
|
|
|
|
|
continue;
|
|
|
|
|
if(PositionGetString(POSITION_SYMBOL) != m_symbol)
|
|
|
|
|
continue;
|
|
|
|
|
if((long)PositionGetInteger(POSITION_MAGIC) != m_magic)
|
|
|
|
|
continue;
|
|
|
|
|
const int n = ArraySize(tickets);
|
|
|
|
|
ArrayResize(tickets, n + 1);
|
|
|
|
|
tickets[n] = t;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| Contains — linear membership test over the ticket list. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
bool CHistoryTracker::Contains(const ulong &list[], const ulong ticket)
|
|
|
|
|
{
|
|
|
|
|
for(int i = 0; i < ArraySize(list); i++)
|
|
|
|
|
if(list[i] == ticket)
|
|
|
|
|
return true;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| BuildClosedRecord — reconstruct the closed trade from deal |
|
|
|
|
|
//| history: opening deal (entry price, volume, order comment) and |
|
|
|
|
|
//| closing deal (net PnL, close time). R-multiple = net PnL divided |
|
|
|
|
|
//| by the initial risk amount (|entry - initial_sl| * tick value per |
|
|
|
|
|
//| point * volume). |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
bool CHistoryTracker::BuildClosedRecord(const ulong ticket, SClosedTrade &out)
|
|
|
|
|
{
|
|
|
|
|
ZeroMemory(out);
|
|
|
|
|
if(!HistorySelectByPosition(ticket))
|
|
|
|
|
return false;
|
|
|
|
|
const double tick_size = SymbolInfoDouble(m_symbol, SYMBOL_TRADE_TICK_SIZE);
|
|
|
|
|
const double tick_value = SymbolInfoDouble(m_symbol, SYMBOL_TRADE_TICK_VALUE);
|
|
|
|
|
if(tick_size <= 0.0 || tick_value <= 0.0)
|
|
|
|
|
return false;
|
|
|
|
|
double profit = 0.0;
|
|
|
|
|
double volume = 0.0;
|
|
|
|
|
double entry_price = 0.0;
|
|
|
|
|
datetime close_time = 0;
|
|
|
|
|
string comment = "";
|
|
|
|
|
bool have_close = false;
|
2026-08-16 11:12:59 +07:00
|
|
|
//--- Gate B evidence accumulators (additive observability) ---
|
|
|
|
|
long position_type = -1; // from the opening deal
|
|
|
|
|
double exit_price = 0.0; // volume-weighted
|
|
|
|
|
double exit_volume = 0.0;
|
|
|
|
|
ulong exit_deal = 0;
|
|
|
|
|
long deal_reason = 0;
|
|
|
|
|
double swap_sum = 0.0;
|
|
|
|
|
double commission_sum= 0.0;
|
2026-08-12 19:54:12 +07:00
|
|
|
const int n = HistoryDealsTotal();
|
|
|
|
|
for(int i = 0; i < n; i++)
|
|
|
|
|
{
|
|
|
|
|
const ulong deal = HistoryDealGetTicket(i);
|
|
|
|
|
if(deal == 0)
|
|
|
|
|
continue;
|
|
|
|
|
if((long)HistoryDealGetInteger(deal, DEAL_POSITION_ID) != (long)ticket)
|
|
|
|
|
continue;
|
|
|
|
|
const long entry_type = HistoryDealGetInteger(deal, DEAL_ENTRY);
|
|
|
|
|
if(entry_type == DEAL_ENTRY_IN)
|
|
|
|
|
{
|
|
|
|
|
volume = HistoryDealGetDouble(deal, DEAL_VOLUME);
|
2026-08-16 11:12:59 +07:00
|
|
|
position_type = (HistoryDealGetInteger(deal, DEAL_TYPE) == DEAL_TYPE_BUY) ? POSITION_TYPE_BUY : POSITION_TYPE_SELL; // Gate B evidence
|
2026-08-12 19:54:12 +07:00
|
|
|
entry_price = HistoryDealGetDouble(deal, DEAL_PRICE);
|
|
|
|
|
const ulong order = HistoryDealGetInteger(deal, DEAL_ORDER);
|
|
|
|
|
if(order != 0 && HistoryOrderSelect(order))
|
|
|
|
|
comment = HistoryOrderGetString(order, ORDER_COMMENT);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
if(entry_type == DEAL_ENTRY_OUT || entry_type == DEAL_ENTRY_INOUT)
|
|
|
|
|
{
|
|
|
|
|
// net outcome in deposit currency (raw PnL + swap + commission)
|
|
|
|
|
profit += HistoryDealGetDouble(deal, DEAL_PROFIT)
|
|
|
|
|
+ HistoryDealGetDouble(deal, DEAL_SWAP)
|
|
|
|
|
+ HistoryDealGetDouble(deal, DEAL_COMMISSION);
|
|
|
|
|
close_time = (datetime)HistoryDealGetInteger(deal, DEAL_TIME);
|
|
|
|
|
have_close = true;
|
2026-08-16 11:12:59 +07:00
|
|
|
//--- Gate B evidence accumulators (additive) ---
|
|
|
|
|
swap_sum += HistoryDealGetDouble(deal, DEAL_SWAP);
|
|
|
|
|
commission_sum += HistoryDealGetDouble(deal, DEAL_COMMISSION);
|
|
|
|
|
const double dvol = HistoryDealGetDouble(deal, DEAL_VOLUME);
|
|
|
|
|
const double dprc = HistoryDealGetDouble(deal, DEAL_PRICE);
|
|
|
|
|
if(dvol > 0.0)
|
|
|
|
|
{
|
|
|
|
|
exit_price += dvol * dprc;
|
|
|
|
|
exit_volume += dvol;
|
|
|
|
|
}
|
|
|
|
|
exit_deal = deal;
|
|
|
|
|
deal_reason = HistoryDealGetInteger(deal, DEAL_REASON);
|
2026-08-12 19:54:12 +07:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-16 11:12:59 +07:00
|
|
|
if(exit_volume > 0.0) // Gate B evidence: volume-weighted exit price
|
|
|
|
|
exit_price /= exit_volume;
|
2026-08-12 19:54:12 +07:00
|
|
|
if(!have_close || volume <= 0.0)
|
|
|
|
|
return false;
|
|
|
|
|
//--- recover the original AI score and initial SL from the comment ---
|
|
|
|
|
double score = -1.0, initial_sl = 0.0;
|
|
|
|
|
if(!ParseCenComment(comment, score, initial_sl))
|
|
|
|
|
return false; // not a Centaur trade
|
|
|
|
|
//--- initial risk amount in deposit currency ---
|
|
|
|
|
const double risk_per_lot = MathAbs(entry_price - initial_sl) * (tick_value / tick_size);
|
|
|
|
|
if(risk_per_lot <= 0.0)
|
|
|
|
|
return false;
|
|
|
|
|
const double risk_amount = risk_per_lot * volume;
|
|
|
|
|
out.valid = true;
|
|
|
|
|
out.ticket = ticket;
|
|
|
|
|
out.profit = profit;
|
|
|
|
|
out.r_multiple = (risk_amount > 0.0) ? profit / risk_amount : 0.0;
|
|
|
|
|
out.initial_ai_score = score;
|
|
|
|
|
out.close_time = close_time;
|
|
|
|
|
out.symbol = m_symbol;
|
2026-08-16 11:12:59 +07:00
|
|
|
//--- Gate B evidence fields (additive) ---
|
|
|
|
|
out.entry_price = entry_price;
|
|
|
|
|
out.initial_sl = initial_sl;
|
|
|
|
|
out.position_type = position_type;
|
|
|
|
|
out.exit_price = exit_price;
|
|
|
|
|
out.exit_volume = exit_volume;
|
|
|
|
|
out.exit_deal = exit_deal;
|
|
|
|
|
out.deal_reason = deal_reason;
|
|
|
|
|
out.swap = swap_sum;
|
|
|
|
|
out.commission = commission_sum;
|
2026-08-12 19:54:12 +07:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| ParseCenComment — extract "CEN:<score>:<sl>" from the comment. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
bool CHistoryTracker::ParseCenComment(const string comment, double &score, double &initial_sl)
|
|
|
|
|
{
|
|
|
|
|
score = -1.0;
|
|
|
|
|
initial_sl = 0.0;
|
|
|
|
|
const int p = StringFind(comment, "CEN:");
|
|
|
|
|
if(p < 0)
|
|
|
|
|
return false;
|
|
|
|
|
const string rest = StringSubstr(comment, p + 4);
|
|
|
|
|
const int sep = StringFind(rest, ":");
|
|
|
|
|
if(sep < 0)
|
|
|
|
|
return false;
|
|
|
|
|
score = StringToDouble(StringSubstr(rest, 0, sep));
|
|
|
|
|
initial_sl = StringToDouble(StringSubstr(rest, sep + 1));
|
|
|
|
|
return (score >= 0.0 && initial_sl > 0.0);
|
|
|
|
|
}
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| Enqueue — append a closed-trade record to the pending queue. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
void CHistoryTracker::Enqueue(const SClosedTrade &rec)
|
|
|
|
|
{
|
|
|
|
|
const int n = ArraySize(m_pending);
|
|
|
|
|
ArrayResize(m_pending, n + 1);
|
|
|
|
|
m_pending[n] = rec;
|
|
|
|
|
}
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| CompactQueue — shift live records to the front and shrink. |
|
|
|
|
|
//| (ArrayCopy is not permitted on struct arrays — manual shift.) |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
void CHistoryTracker::CompactQueue()
|
|
|
|
|
{
|
|
|
|
|
const int size = ArraySize(m_pending);
|
|
|
|
|
if(m_pending_head <= 0)
|
|
|
|
|
return;
|
|
|
|
|
const int remaining = size - m_pending_head;
|
|
|
|
|
for(int i = 0; i < remaining; i++)
|
|
|
|
|
m_pending[i] = m_pending[m_pending_head + i];
|
|
|
|
|
ArrayResize(m_pending, remaining);
|
|
|
|
|
m_pending_head = 0;
|
|
|
|
|
}
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| PendingCount — closed trades awaiting delivery. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
int CHistoryTracker::PendingCount() const
|
|
|
|
|
{
|
|
|
|
|
return ArraySize(m_pending) - m_pending_head;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endif // HISTORYTRACKER_MQH
|