//+------------------------------------------------------------------+ //| Warrior_EA | //| AnimateDread | //| | //| PERSISTENCE FOR THE VOTE ARROWS - the filtered view's own layer. | //+------------------------------------------------------------------+ #ifndef WARRIOR_CHART_VOTEARROWS_MQH #define WARRIOR_CHART_VOTEARROWS_MQH //+------------------------------------------------------------------+ //| WHY THIS EXISTS AS ITS OWN STORE. | //| | //| With DrawUnfilteredSignals OFF - the shipped default - the arrows | //| on the chart are the COMBINED VOTE's (SIG_VOTE_PREFIX), drawn by | //| the aggregate signal's overlay. They are the ones that answer | //| "where would this EA actually have traded", because the overlay | //| only draws a bar whose |vote| cleared Signal_ThresholdOpen. | //| | //| Nothing persisted them. CChartUI's .arrows sidecar is MEMBER- | //| scoped (it scans m_view.ArrowPrefix()), so it never saw this | //| layer at all, and the only route back was a full overlay re-sweep.| //| That sweep replays each member's m_overlaySigSnap - an IN-MEMORY | //| cache filled at pass-3 completion - and it re-arms only when an | //| era ends. A DEPLOYED ensemble runs no further eras. So on a | //| terminal restart the arrows were gone AND unrecoverable: no file | //| to read them from, and no era ever coming to redraw them. | //| | //| THE THRESHOLDS ARE PART OF THE RECORD. An arrow means "the vote | //| cleared the bar to open", so it is only true relative to the | //| threshold it was measured against. The header stores the open and | //| close thresholds the arrows were drawn under; on load a mismatch | //| DISCARDS them rather than restoring a picture of a strategy the | //| operator no longer runs. Showing stale arrows under a new | //| threshold is worse than showing none: none is visibly empty, | //| stale is confidently wrong. | //+------------------------------------------------------------------+ #include "..\..\System\AtomicFile.mqh" //--- 'WVA1'. Bump only if the record layout changes; the thresholds live IN the record, so a //--- threshold change is a data mismatch, not a format one. #define VOTE_ARROWS_MAGIC 0x57564131 //--- Same retention and pacing as the member arrows (see MAX_PERSISTED_ARROWS / ARROW_RESTORE_BUDGET_MS //--- in ExpertSignalAIBase.mqh). Duplicated as its own names rather than borrowed: this layer holds one //--- arrow per TRADE rather than one per model opinion, so it is legitimately allowed to diverge. #define VOTE_ARROWS_MAX_KEPT 1000 #define VOTE_ARROWS_MAX_HEADER 50000 #define VOTE_ARROWS_BUDGET_MS 50 //+------------------------------------------------------------------+ //| Chart-scoped, single instance. The vote belongs to the CHART, not | //| to any member - which is exactly why it could not live in | //| CChartUI, whose every path is scoped to one model's prefix. | //+------------------------------------------------------------------+ class CVoteArrowStore { private: string m_key; // file basename, set once at init double m_thresholdOpen; // what the stored arrows were measured against double m_thresholdClose; bool m_active; // false in tester/optimizer and in the raw view //--- parsed-but-not-yet-drawn, consumed by AdvanceRestore() datetime m_time[]; int m_code[]; double m_price[]; long m_tf[]; int m_index; bool m_pending; uint m_startMs; int m_lastSaved; //--- DEINIT SPLIT. Snapshot() is the chart-scan half of a save, held in memory with ZERO disk //--- I/O, so OnDeinit can capture the arrows and purge the chart immediately; WriteSnapshot() //--- is the disk half, run later with the heavy persistence. Measured 2026-08-25 18:23: two of //--- six charts never reached their first cleanup step because the un-split Save() (a file //--- write) sat ahead of it while four sibling charts flooded the same disk - "Abnormal //--- termination" 5.9 s in, objects stranded. long m_snapTimes[]; int m_snapCodes[]; double m_snapPrices[]; long m_snapTfs[]; int m_snapCount; bool m_snapTaken; string FileName(void) const { return m_key + ".votearrows"; } public: CVoteArrowStore(void) : m_key(""), m_thresholdOpen(-1.0), m_thresholdClose(-1.0), m_active(false), m_index(0), m_pending(false), m_startMs(0), m_lastSaved(0), m_snapCount(0), m_snapTaken(false) {} bool Active(void) const { return m_active; } bool Pending(void) const { return m_pending; } int LastSaved(void) const { return m_lastSaved; } void Configure(const string key, const double thresholdOpen, const double thresholdClose, const bool active); void Load(void); void AdvanceRestore(void); void Snapshot(void); bool WriteSnapshot(void); bool Save(void) { Snapshot(); return WriteSnapshot(); } void Discard(const string reason); }; CVoteArrowStore g_voteArrows; //+------------------------------------------------------------------+ //| Bind the store to this chart's configuration. `active` is false | //| in the tester (throwaway charts) and in the raw view, where the | //| per-member layer owns the chart and this one draws nothing. | //+------------------------------------------------------------------+ void CVoteArrowStore::Configure(const string key, const double thresholdOpen, const double thresholdClose, const bool active) { m_key = key; m_thresholdOpen = thresholdOpen; m_thresholdClose = thresholdClose; m_active = active; } //+------------------------------------------------------------------+ //| Read the sidecar and QUEUE it. Deliberately draws nothing here - | //| same reason CChartUI::LoadChartSignals defers: OnInit must not | //| block, and this queue can hold a thousand object pairs. | //+------------------------------------------------------------------+ void CVoteArrowStore::Load(void) { if(!m_active || m_key == "") return; string fn = FileName(); if(!FileIsExist(fn, FILE_COMMON)) return; //--- share flags: read-only, and must not fail because another instance holds the file. int handle = FileOpen(fn, FILE_COMMON | FILE_BIN | FILE_READ | FILE_SHARE_READ | FILE_SHARE_WRITE); if(handle == INVALID_HANDLE) return; if(FileReadInteger(handle) != VOTE_ARROWS_MAGIC) { FileClose(handle); return; } double savedOpen = FileReadDouble(handle); double savedClose = FileReadDouble(handle); int n = FileReadInteger(handle); FileClose(handle); //--- THE THRESHOLD GATE. See the header note: an arrow is a claim about a threshold, so a changed //--- threshold makes every stored arrow a claim about a strategy that is no longer configured. if(savedOpen != m_thresholdOpen || savedClose != m_thresholdClose) { Discard(StringFormat("thresholds changed (stored open %.0f / close %.0f, now open %.0f / close %.0f)" " - those arrows describe a strategy this chart no longer runs, so they are" " discarded rather than redrawn. They rebuild from the vote as the models run.", savedOpen, savedClose, m_thresholdOpen, m_thresholdClose)); return; } if(n <= 0 || n > VOTE_ARROWS_MAX_HEADER) { if(n > VOTE_ARROWS_MAX_HEADER) Print("Warrior: .votearrows header declares " + IntegerToString(n) + " arrows - refusing to restore (limit " + IntegerToString(VOTE_ARROWS_MAX_HEADER) + "); the file looks corrupt."); return; } //--- Reopen to stream the records: the header read above is cheap and lets a mismatched/corrupt //--- file be rejected before any array is sized to it. handle = FileOpen(fn, FILE_COMMON | FILE_BIN | FILE_READ | FILE_SHARE_READ | FILE_SHARE_WRITE); if(handle == INVALID_HANDLE) return; FileReadInteger(handle); FileReadDouble(handle); FileReadDouble(handle); FileReadInteger(handle); ArrayResize(m_time, n); ArrayResize(m_code, n); ArrayResize(m_price, n); ArrayResize(m_tf, n); int parsed = 0; for(int i = 0; i < n && !FileIsEnding(handle); i++) { m_time[parsed] = (datetime)FileReadLong(handle); m_code[parsed] = FileReadInteger(handle); m_price[parsed] = FileReadDouble(handle); m_tf[parsed] = FileReadLong(handle); parsed++; } FileClose(handle); //--- A truncated file yields fewer records than the header claimed - keep what parsed. if(parsed != n) { ArrayResize(m_time, parsed); ArrayResize(m_code, parsed); ArrayResize(m_price, parsed); ArrayResize(m_tf, parsed); } m_index = 0; m_pending = (parsed > 0); m_startMs = GetTickCount(); if(m_pending) Print("Warrior: queued " + IntegerToString(parsed) + " combined-vote arrows for progressive restore" " (threshold to open " + DoubleToString(m_thresholdOpen, 0) + "%)."); } //+------------------------------------------------------------------+ //| Draw a time-boxed slice. Bounds THROUGHPUT, not latency to a | //| stop - hence the IsStopped() check inside the loop as well. | //+------------------------------------------------------------------+ void CVoteArrowStore::AdvanceRestore(void) { if(!m_pending) return; int total = ArraySize(m_time); uint sliceStart = GetTickCount(); int drawn = 0; while(m_index < total) { //--- Budget checked every 64 objects: GetTickCount() is not free, and at microseconds per object //--- a per-iteration clock read would dominate the work being measured. if(drawn > 0 && (drawn & 63) == 0 && GetTickCount() - sliceStart >= VOTE_ARROWS_BUDGET_MS) break; if(drawn > 0 && (drawn & 63) == 0 && IsStopped()) return; datetime t = m_time[m_index]; int code = m_code[m_index]; double price = m_price[m_index]; long tf = m_tf[m_index]; m_index++; drawn++; bool isBuy = (code == WARRIOR_SIG_CODE_BUY); string nm = SIG_VOTE_PREFIX + TimeToString(t); //--- isTrade=true: this layer IS the trade layer, and it must keep the heavier styling that //--- distinguishes it from a member's raw opinion (see WarriorPlotSignalLevel). WarriorPlotSignalLevel(nm, t, (ENUM_TIMEFRAMES)Period(), price, isBuy, true, StringFormat("would trade %s @ %s | vote cleared %.0f%% | restored from the" " previous session", (isBuy ? "BUY" : "SELL"), DoubleToString(price, (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS)), m_thresholdOpen)); //--- Both halves, or a restore made while signals are hidden brings the lines back alone. ObjectSetInteger(0, nm, OBJPROP_TIMEFRAMES, tf); ObjectSetInteger(0, WarriorSignalArrowName(nm), OBJPROP_TIMEFRAMES, tf); } ChartRedraw(0); if(m_index < total) return; // more slices to come m_pending = false; Print("Warrior: restored " + IntegerToString(total) + " combined-vote arrows in " + IntegerToString((int)(GetTickCount() - m_startMs)) + " ms (progressive, non-blocking)."); ArrayFree(m_time); ArrayFree(m_code); ArrayFree(m_price); ArrayFree(m_tf); } //+------------------------------------------------------------------+ //| SCAN HALF of a save: capture the drawn vote arrows into memory, | //| zero disk I/O. See the m_snap* declaration comment - this is what | //| lets OnDeinit purge the chart before any file write can block on | //| a contended disk. | //| | //| Unlike the member layer a save does NOT clear the chart: the | //| overlay redraws this layer from scratch on every sweep, so the | //| file is a mirror of the chart rather than its only copy. | //+------------------------------------------------------------------+ void CVoteArrowStore::Snapshot(void) { m_snapCount = 0; m_snapTaken = true; if(!m_active || m_key == "") return; int n = 0; int total = ObjectsTotal(0); //--- Room for the chart's arrows PLUS anything still sitting in the restore queue: a save that //--- runs mid-restore would otherwise write only the part already drawn and silently truncate the //--- history to the slice that happened to have finished. int queueRemaining = m_pending ? (ArraySize(m_time) - m_index) : 0; if(queueRemaining < 0) queueRemaining = 0; ArrayResize(m_snapTimes, total + queueRemaining); ArrayResize(m_snapCodes, total + queueRemaining); ArrayResize(m_snapPrices, total + queueRemaining); ArrayResize(m_snapTfs, total + queueRemaining); for(int i = 0; i < total; i++) { string nm = ObjectName(0, i); if(StringFind(nm, SIG_VOTE_PREFIX) != 0) continue; //--- OBJ_TREND only: a mark is a line AND an arrow, and the line is the canonical half (it //--- carries the trigger price). Counting both would write every arrow twice. if(ObjectGetInteger(0, nm, OBJPROP_TYPE) != OBJ_TREND) continue; m_snapTimes[n] = (long)ObjectGetInteger(0, nm, OBJPROP_TIME, 0); //--- Direction from the COLOUR - a line carries no arrow code. Same encoding as the member //--- sidecar so both files stay readable by one convention. m_snapCodes[n] = ((color)ObjectGetInteger(0, nm, OBJPROP_COLOR) == WARRIOR_SIG_BUY_COLOR) ? WARRIOR_SIG_CODE_BUY : WARRIOR_SIG_CODE_SELL; m_snapPrices[n] = ObjectGetDouble(0, nm, OBJPROP_PRICE, 0); m_snapTfs[n] = g_signalsVisible ? OBJ_ALL_PERIODS : OBJ_NO_PERIODS; n++; } for(int q = m_index; q < m_index + queueRemaining; q++) { m_snapTimes[n] = (long)m_time[q]; m_snapCodes[n] = m_code[q]; m_snapPrices[n] = m_price[q]; m_snapTfs[n] = g_signalsVisible ? OBJ_ALL_PERIODS : OBJ_NO_PERIODS; n++; } //--- Keep the most recent VOTE_ARROWS_MAX_KEPT by TIME (scan order is object order, not time order). if(n > VOTE_ARROWS_MAX_KEPT) { long sortedTimes[]; ArrayResize(sortedTimes, n); ArrayCopy(sortedTimes, m_snapTimes, 0, 0, n); ArraySort(sortedTimes); // ascending long cutoff = sortedTimes[n - VOTE_ARROWS_MAX_KEPT]; int w = 0; for(int i = 0; i < n; i++) { //--- ">= cutoff" can match more than the cap when several arrows share the cut-off timestamp; //--- the w < cap test keeps the kept set at exactly the cap in that case. if(m_snapTimes[i] >= cutoff && w < VOTE_ARROWS_MAX_KEPT) { m_snapTimes[w] = m_snapTimes[i]; m_snapCodes[w] = m_snapCodes[i]; m_snapPrices[w] = m_snapPrices[i]; m_snapTfs[w] = m_snapTfs[i]; w++; } } n = w; } m_snapCount = n; } //+------------------------------------------------------------------+ //| DISK HALF: write the captured snapshot, thresholds included. | //| Consuming - the snapshot is freed either way, so a stale capture | //| can never be replayed over a fresher chart. | //+------------------------------------------------------------------+ bool CVoteArrowStore::WriteSnapshot(void) { if(!m_snapTaken) return true; m_snapTaken = false; int n = m_snapCount; m_snapCount = 0; bool ok = true; //--- NOTHING DRAWN IS NOT NOTHING TO SAY, but it is not a reason to destroy the record either: a //--- chart that has not swept yet has no arrows and would otherwise wipe a good file on shutdown. if(m_active && m_key != "" && n > 0) { string fn = FileName(); string tmpName = ""; int handle = AtomicWriteBegin(fn, FILE_COMMON, tmpName); if(handle == INVALID_HANDLE) { Print("Warrior: ERROR - could not open " + tmpName + " to persist " + IntegerToString(n) + " combined-vote arrows, error " + IntegerToString(GetLastError())); ok = false; } else { ok = (FileWriteInteger(handle, VOTE_ARROWS_MAGIC) > 0); if(ok && FileWriteDouble(handle, m_thresholdOpen) <= 0) ok = false; if(ok && FileWriteDouble(handle, m_thresholdClose) <= 0) ok = false; if(ok && FileWriteInteger(handle, n) <= 0) ok = false; for(int i = 0; ok && i < n; i++) { if(FileWriteLong(handle, m_snapTimes[i]) <= 0 || FileWriteInteger(handle, m_snapCodes[i]) <= 0 || FileWriteDouble(handle, m_snapPrices[i]) <= 0 || FileWriteLong(handle, m_snapTfs[i]) <= 0) ok = false; } //--- Staged + atomically renamed, so an interrupted write keeps the previous arrow set rather //--- than publishing a truncated one. if(!AtomicWriteEnd(handle, fn, tmpName, FILE_COMMON, ok, __FUNCTION__)) ok = false; else if(ok) m_lastSaved = n; } } ArrayFree(m_snapTimes); ArrayFree(m_snapCodes); ArrayFree(m_snapPrices); ArrayFree(m_snapTfs); return ok; } //+------------------------------------------------------------------+ //| Drop the stored arrows and the queue, and say why on the journal. | //| The chart objects go too: whatever reason invalidated the file | //| invalidates what is drawn from it. | //+------------------------------------------------------------------+ void CVoteArrowStore::Discard(const string reason) { m_pending = false; m_index = 0; ArrayFree(m_time); ArrayFree(m_code); ArrayFree(m_price); ArrayFree(m_tf); ObjectsDeleteAll(0, SIG_VOTE_PREFIX); if(m_key != "" && FileIsExist(FileName(), FILE_COMMON)) FileDelete(FileName(), FILE_COMMON); ChartRedraw(0); Print("Warrior: combined-vote arrows cleared - " + reason); } #endif // WARRIOR_CHART_VOTEARROWS_MQH //+------------------------------------------------------------------+