forked from animatedread/Warrior_EA
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>
188 lines
7.9 KiB
MQL5
188 lines
7.9 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| 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
|