Warrior_EA/System/AltDataHttpClient.mqh

188 lines
7.9 KiB
MQL5
Raw Permalink Normal View History

refactor(altdata): split CAltDataFetch's HTTP client and symbol catalog into collaborators CAltDataFetch (System/AltDataFetch.mqh) mixed six unrelated concerns in one 1406-line class. This is plain composition, not the Expert/AIBase view+adapter pattern - CAltDataFetch has no single-inheritance parent forcing an adapter, same shape as Database/DatabaseManager.mqh composing its four Database* managers. Extracted, grep-confirmed zero external callers of any moved method (only Warrior_EA.mq5's already-public Update/NeedsMapping/CatalogCount/CatalogName/ CatalogLabel/SaveUserMapping surface, unchanged): - CAltDataHttpClient (System/AltDataHttpClient.mqh): HttpGet/HostOf/MaskUrl/ BlockedIndex (4014 per-host backoff), JsonField, UrlEncodePart. Owns the m_blockedHost/m_blockedUntil/m_urlAlerted state - STATEFUL, moved verbatim. - CAltDataCatalog (System/AltDataCatalog.mqh): the SAltSymbolSpec catalog (AddSpec/BuildCatalog/AliasMatches/FindSpec) plus symbol_map.cfg user-mapping persistence (LoadUserMap/SaveUserMapping) and the public NeedsMapping/CatalogCount/CatalogName/CatalogLabel surface. Owns m_specs/m_userFrom/m_userTo/m_userMapLoaded - STATEFUL, moved verbatim. Added one new Spec(i) getter (by value - 4 strings + 1 double, cheap) so CAltDataFetch::Update()/RebuildFeatures() can read a resolved catalog row without reaching into the collaborator's array. - LoadRaw/SaveRaw (raw-series CSV <-> file mapping) had zero member-state dependency - turned into free functions AltRawLoad/AltRawSave, matching the file's own existing AltSeriesAppend precedent, instead of a needless class. CAltDataFetch itself keeps three concerns as a deliberate partial, same judgment already applied to Topology's boot sequence / Features' shared- indicator lifecycle elsewhere in this campaign: the four per-source fetch pipelines (UpdateFred/UpdateCot/UpdateEia/UpdateGex, including FredKey/EiaKey/ LoadCommonKey/ShouldAttemptFetch/the GEX CBOE helpers) and the feature-CSV builder (RebuildFeatures/FeatureValue/RollingPctRank/AsOf) both read/write the 9 SAltRawSeries caches kept resident on the orchestrator between timer ticks - splitting them out would mean either relocating that cache's ownership or a 9-13 parameter signature per method, a larger design decision better made as its own pass rather than forced through unattended given the finding's own "high risk" estimate. Every moved method body is copied verbatim (statement-by-statement diffed against git show HEAD~1:System/AltDataFetch.mqh) with only the mechanical substitution HttpGet/JsonField/UrlEncodePart -> m_http.*, LoadRaw/SaveRaw -> AltRawLoad/AltRawSave, FindSpec -> m_catalog.FindSpec, and m_specs[si].X -> spec.X (spec = m_catalog.Spec(si), resolved once per Update() call instead of re-indexing). ALTFETCH_TIMEOUT_MS/ALTFETCH_GEX_TIMEOUT_MS macros moved into AltDataHttpClient.mqh (their logical owner); ALTFETCH_DIR stays in AltDataFetch.mqh, defined before both new #includes since CAltDataCatalog's ctor path (symbol_map.cfg) and CAltDataHttpClient's HttpGet default param reference it. Self-compiled 0 errors, 0 warnings (_claude_stage, MetaEditor64 /compile), twice - once before and once after a stale doc-comment fix (a leftover "same reasoning as SaveRaw" mention updated to AltRawSave). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 03:32:02 -04:00
//+------------------------------------------------------------------+
//| AltDataHttpClient.mqh |
//| AnimateDread |
//| Generic HTTP client for AltDataFetch's per-source pipelines: the |
//| per-host 4014 backoff, URL masking for the journal, and the |
//| minimal JSON/URL-encoding helpers the Socrata/FRED/EIA/CBOE |
//| responses need. Knows nothing about FRED/COT/EIA/GEX - only how |
//| to fetch a URL and parse a flat JSON body. |
//+------------------------------------------------------------------+
#ifndef ALTDATAHTTPCLIENT_MQH
#define ALTDATAHTTPCLIENT_MQH
#define ALTFETCH_TIMEOUT_MS 15000
//--- the SPX option chain is ~13 MB; the small feeds' 15 s would time out on a slow link
#define ALTFETCH_GEX_TIMEOUT_MS 90000
class CAltDataHttpClient
{
private:
string m_blockedHost[]; // 4014 backoff, PER HOST - one missing whitelist entry
datetime m_blockedUntil[]; // must not silence the hosts that are whitelisted
bool m_urlAlerted; // full four-line reference printed once per session
//--- "https://host/..." -> "https://host" (what the MT5 whitelist dialog wants)
string HostOf(string url)
{
int p = StringFind(url, "://");
int q = StringFind(url, "/", p < 0 ? 0 : p + 3);
return (q < 0) ? url : StringSubstr(url, 0, q);
}
//--- URL safe for the JOURNAL: api_key values masked. The 2026-08-16 EIA failure
//--- printed the first 80 chars of the URL - which included most of the key.
//--- Every error path that echoes a URL must go through this.
string MaskUrl(string url)
{
int p = StringFind(url, "api_key=");
if(p < 0)
return StringSubstr(url, 0, 100);
int e = StringFind(url, "&", p);
string masked = StringSubstr(url, 0, p + 8) + "***";
if(e > 0)
masked += StringSubstr(url, e);
return StringSubstr(masked, 0, 100);
}
int BlockedIndex(string host)
{
for(int i = 0; i < ArraySize(m_blockedHost); i++)
if(m_blockedHost[i] == host)
return i;
return -1;
}
public:
CAltDataHttpClient(void) : m_urlAlerted(false) {}
bool HttpGet(string url, string &body, int timeoutMs = ALTFETCH_TIMEOUT_MS)
{
//--- PER-HOST backoff. An in-flight request cannot be cancelled; refusing to START another
//--- one is the whole of the remedy.
if(IsStopped())
return false;
string host = HostOf(url);
int bi = BlockedIndex(host);
if(bi >= 0 && TimeCurrent() < m_blockedUntil[bi])
return false;
char data[], result[];
string rh;
ResetLastError();
//--- identify ourselves: some API gateways reject requests with an empty User-Agent
//--- at the transport level (candidate cause of the EIA 1003 on first live fetch)
int status = WebRequest("GET", url, "User-Agent: WarriorEA/1.0\r\n", timeoutMs, data, result, rh);
if(status == -1)
{
int err = GetLastError();
if(err == 4014)
{
//--- Backoff, not a permanent latch: the whitelist takes effect for a RUNNING EA
//--- the moment the user saves the options dialog, so retrying hourly picks the
//--- fix up without a re-attach.
bool firstTimeThisHost = (bi < 0);
if(bi < 0)
{
bi = ArraySize(m_blockedHost);
ArrayResize(m_blockedHost, bi + 1);
ArrayResize(m_blockedUntil, bi + 1);
m_blockedHost[bi] = host;
}
m_blockedUntil[bi] = TimeCurrent() + 3600;
if(firstTimeThisHost)
{
//--- ALWAYS name the offender - the whole point of this message is telling
//--- the user which entry is missing, not that "something" is
Alert("Warrior EA: web request to ", host, " is blocked (4014) - this exact ",
"address is missing from the WebRequest whitelist (or the checkbox is ",
"off). Copy-paste line and steps are in the Experts log.");
Print("AltDataFetch: ==================================================================");
Print("AltDataFetch: BLOCKED host (add this exact line as its own whitelist entry):");
Print(host);
Print("AltDataFetch: MT5 menu: Tools > Options > Expert Advisors ->");
Print("AltDataFetch: tick 'Allow WebRequest for listed URL', add the line, click OK.");
Print("AltDataFetch: Takes effect immediately - the EA retries within the hour.");
if(!m_urlAlerted)
{
m_urlAlerted = true;
Print("AltDataFetch: For reference, the FULL set this EA uses (each its own entry):");
Print("https://publicreporting.cftc.gov");
Print("https://api.stlouisfed.org");
Print("https://api.eia.gov");
Print("https://cdn.cboe.com");
Print("AltDataFetch: Until fixed it runs normally on cached alt-data files - features");
Print("AltDataFetch: stay valid but stop updating past their last downloaded date.");
}
Print("AltDataFetch: ==================================================================");
}
else
Print("AltDataFetch: still blocked (4014): " + host + " - retrying in an hour.");
}
else
PrintFormat("AltDataFetch: WebRequest error %d for %s", err, MaskUrl(url));
return false;
}
if(status != 200)
{
PrintFormat("AltDataFetch: HTTP %d for %s%s", status, MaskUrl(url),
status >= 1000 ? " (1xxx = MT5 transport-layer failure, not a server "
"response - retried on the normal hourly schedule)" : "");
return false;
}
body = CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8);
return true;
}
//--- minimal targeted JSON scan: value of "key":"..." (or numeric) at/after position `from`.
//--- Sufficient for Socrata/FRED responses, which are flat arrays of flat objects.
string JsonField(const string &body, string key, int from, int &valueEnd)
{
valueEnd = -1;
string tag = "\"" + key + "\":";
int p = StringFind(body, tag, from);
if(p < 0)
return "";
p += StringLen(tag);
if(StringGetCharacter(body, p) == '"')
{
int q = StringFind(body, "\"", p + 1);
if(q < 0)
return "";
valueEnd = q + 1;
return StringSubstr(body, p + 1, q - p - 1);
}
int e = p;
while(e < StringLen(body))
{
ushort c = StringGetCharacter(body, e);
if((c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E')
e++;
else
break;
}
valueEnd = e;
return StringSubstr(body, p, e - p);
}
string UrlEncodePart(string s)
{
string out = "";
for(int i = 0; i < StringLen(s); i++)
{
ushort c = StringGetCharacter(s, i);
//--- '%' MUST come first and MUST be escaped: SoQL like-predicates use it as the
//--- wildcard ('EURO FX -%'), and an unescaped one turns the rest of the query into
//--- a malformed percent-escape.
if(c == '%') out += "%25";
else if(c == ' ') out += "%20";
else if(c == '\'') out += "%27";
else if(c == '&') out += "%26";
else if(c == '>') out += "%3E";
else if(c == '=') out += "%3D";
else if(c == '+') out += "%2B";
else if(c == '#') out += "%23";
else out += ShortToString(c);
}
return out;
}
};
#endif