Persistent Key-Value Store in MQL5: Using Flat Files as a Lightweight Database for EA State
Introduction
Every terminal restart silently wipes an Expert Advisor's runtime memory. Counters reset, flags disappear, and execution resumes as though the EA had just been attached to the chart. For simple systems this may be only an inconvenience, but for production-grade trading software it can break risk management, invalidate execution logic, and erase valuable operational context accumulated during previous sessions.
MetaTrader 5 Expert Advisors (EAs) execute within a runtime environment managed entirely by the trading terminal. When the terminal closes, all volatile runtime state is cleared, including counters, flags, and session timestamps. Upon initialization, the EA reverts to its compiled default state. This behavior is a standard characteristic of managed execution environments rather than a deficiency within the MQL5 runtime. Architectural vulnerabilities arise if an EA stores operational state only in global variables and has no independent persistence mechanism to restore it after termination.
In production environments, the absence of a structured persistence layer typically impacts several critical operational domains:
- Session Metrics and Trade Counters: Cumulative realized profit targets, drawdown trackers, and daily or weekly trade limits reset to zero upon a terminal restart, which can cause the EA to resume execution in breach of defined session risk parameters.
- Regime Detection States: Structural market classification flags—such as trend versus mean-reversion modes, volatility filters, and asset correlation states—are wiped, forcing the system to reclassify the immediate market environment.
- Optimization Timestamps: EAs designed to trigger offline parameter optimization on a fixed schedule lose their historical execution records, resulting in redundant, resource-intensive reoptimizations upon restart.
- Dynamic Risk Settings and Feature Toggles: Variables governing position-sizing multipliers, margin utilization metrics, and conditional runtime filters (such as news blocks or spread thresholds) disappear, disrupting risk management continuity.
These variables represent the core operational state required for robust algorithmic execution. Without an explicit persistence mechanism, every terminal interruption forces a reset equivalent to deploying a new instance with no historical session context.
Rather than relying on SQLite, terminal GlobalVariables, or external storage mechanisms, this article develops a lightweight persistence layer implemented entirely in native MQL5. The solution combines a human-readable flat-file format with a typed API, an in-memory cache, and a modular storage architecture that preserves EA state across terminal restarts while remaining simple to inspect, extend, and integrate into existing projects.
This article examines the architectural strategies required to implement a reliable state persistence layer within MQL5, ensuring deterministic continuity across terminal restarts.
Why GlobalVariables Are Insufficient
MQL5 provides GlobalVariableSet() and GlobalVariableGet() as a built-in mechanism for storing named values across EA instances. GlobalVariables persist on disk in a proprietary binary format managed by the terminal. They appear to solve the problem; however, they introduce several constraints that limit their usefulness in production systems.
GlobalVariables occupy a single namespace shared by all EAs running in the same terminal. An EA storing trade_counter as a GlobalVariable will overwrite or read any value stored under the same name by any other EA. Namespacing by account number or EA name requires constructing key strings at runtime and introduces coordination overhead.
GlobalVariables support only double values. An EA needing to store a string such as "conservative" for a risk mode, or a datetime value, or a boolean flag, must encode that value into a double and remember the encoding convention. This encoding is fragile and opaque.
GlobalVariables cannot be inspected without additional code/tools, since the terminal stores it on disk, but the format is not intended for viewing. Debugging requires writing additional diagnostic code that reads and prints them. A flat file stored in MQL5/Files/ can be opened in any text editor for immediate inspection.
GlobalVariables have no concept of grouping or namespacing by EA instance. A flat file naturally provides per-EA isolation because each EA can maintain its own file with a distinct filename.
Flat Files as a Persistence Layer
A flat file stores EA state as a sequence of human-readable lines in the following format:
daily_target_hit=true trade_counter=42 last_optimization=2025.05.01 09:00 risk_mode=conservative
Each line encodes one key-value pair. The key is a string identifier. The value is a string representation of data that may logically represent an integer, a double, a boolean, a datetime, or a plain string. The file is placed in the MQL5/Files/ directory, which is the only location where MQL5 file I/O functions are permitted to operate.
The format is human-readable and portable, and it requires no external libraries, DLL imports, or database engine. A developer can inspect or manually correct the state in any text editor. The format is trivially parseable using string splitting, which MQL5 supports natively via StringSplit().
The limitations are also real. The format has no schema enforcement — any string can be stored as any value, and type consistency must be enforced by the application layer. Sequential updates require rewriting the entire file because the format does not support in-place record replacement. Concurrent access from multiple EA instances writing the same file simultaneously is not safe. These trade-offs are analyzed in detail in the sections below.
Architecture Overview
The implementation is divided into five header modules and one demonstration EA, each responsible for a distinct layer of the system.
| Layer | Module | Responsibility |
|---|---|---|
| Value representation | PersistentValue.mqh | Variant type with type detection helpers |
| Parsing | PersistentParser.mqh | Line tokenization and validation |
| Serialization | PersistentSerializer.mqh | Conversion of cache entries to flat-file text |
| Cache | PersistentCache.mqh | CHashMap-backed in-memory store |
| Store interface | PersistentStore.mqh | Public API — Get, Set, Delete, Exists, file sync |
| Demonstration | PersistentStoreDemo.mq5 | End-to-end usage and restart simulation |
The storage layers and their relationships:
| Layer | Component | Responsibility |
|---|---|---|
| Disk | Flat .kv file in MQL5/Files/ | Persistence across restarts |
| Memory | CHashMap<string,string> | O(1) lookup cache |
| Interface | CPersistentStore | Unified API for EA code |
Key-Value Architecture
Every stored record has two components:
| Component | Type | Purpose |
|---|---|---|
| Key | string | Unique identifier for the stored value |
| Value | string | Serialized representation of any supported type |
Keys must be non-empty strings containing no = character, since = is used as the field delimiter. Values are stored as strings regardless of their logical type. Type inference occurs at retrieval time when the caller requests a specific type, or it can be applied automatically during parsing.
The five core operations and their complexity:
| Operation | Cache Complexity | Disk Complexity |
|---|---|---|
| Get() | O(1) hash map lookup | No disk access |
| Exists() | O(1) hash map lookup | No disk access |
| Set() | O(1) hash map insert | Full file rewrite — O(n) |
| Delete() | O(1) hash map remove | Full file rewrite — O(n) |
| Load() | O(n) | Sequential file read — O(n) |
The O(n) disk cost of Set() and Delete() is unavoidable given the sequential flat-file format. However, because EA state files are typically small — rarely more than a few hundred entries — this cost is acceptable. A file with 100 entries at 40 bytes per line occupies 4 KB. A full rewrite of 4 KB completes in well under one millisecond on any modern storage medium.
Code Listings and Architectural Breakdown
PersistentValue.mqh
CPersistentValue is a single-responsibility wrapper. It holds one stored string and exposes typed accessor methods that perform on-demand conversion. The class never touches disk — it only models what a value is and what types it can represent.
The supported types are declared as an enumeration at the top of the file so that calling code can switch on a value's inferred type when needed:
//+------------------------------------------------------------------+ //| PersistentValue.mqh | //| Lightweight Persistent Key-Value Storage Engine | //+------------------------------------------------------------------+ #ifndef PERSISTENT_VALUE_MQH #define PERSISTENT_VALUE_MQH //+------------------------------------------------------------------+ //| Supported value types for type-safe retrieval | //+------------------------------------------------------------------+ enum ENUM_PERSISTENT_TYPE { PERSISTENT_TYPE_STRING = 0, // Raw string (default) PERSISTENT_TYPE_INT = 1, // 32-bit integer PERSISTENT_TYPE_LONG = 2, // 64-bit integer PERSISTENT_TYPE_DOUBLE = 3, // Floating-point number PERSISTENT_TYPE_BOOL = 4, // Boolean true/false PERSISTENT_TYPE_DATETIME = 5 // Datetime in YYYY.MM.DD HH:MM format };
The six members of ENUM_PERSISTENT_TYPE correspond exactly to the six types the store supports. The STRING variant is assigned zero and acts as the default — any value that does not match a more specific pattern falls back to it. Assigning explicit integer constants to every member prevents the compiler from shifting values if a member is ever reordered.
The class declaration follows. It contains two private data members and one private method, plus a public API of one population method and six typed accessors:
//+------------------------------------------------------------------+ //| CPersistentValue | //| Wraps a raw string value together with its inferred type. | //| All values are stored internally as strings; conversion is | //| performed on demand when a typed accessor is called. | //+------------------------------------------------------------------+ class CPersistentValue { private: string m_raw; // Raw string representation ENUM_PERSISTENT_TYPE m_type; // Inferred or assigned type ENUM_PERSISTENT_TYPE InferType(const string raw); public: CPersistentValue(void); ~CPersistentValue(void); void FromString(const string raw); string AsString(void) const; int AsInt(void) const; long AsLong(void) const; double AsDouble(void) const; bool AsBool(void) const; datetime AsDatetime(void) const; ENUM_PERSISTENT_TYPE Type(void) const; };
m_raw holds the value exactly as it was read from the flat file or supplied by the caller. m_type caches the inferred type so that Type() returns it without repeating the inference work. InferType() is private because no external code needs to classify a string — that logic belongs entirely inside this class.
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CPersistentValue::CPersistentValue(void) { m_raw = ""; m_type = PERSISTENT_TYPE_STRING; } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CPersistentValue::~CPersistentValue(void) { }
The constructor initializes m_raw to an empty string and m_type to PERSISTENT_TYPE_STRING. This establishes a safe default state: an instance that has never had FromString() called behaves as if it holds an empty string value. The destructor has no work to do because m_raw is a value-type string managed by the MQL5 runtime and m_type is a plain enum.
//+------------------------------------------------------------------+ //| Populate value from a raw string and infer its type | //+------------------------------------------------------------------+ void CPersistentValue::FromString(const string raw) { m_raw = raw; m_type = InferType(raw); }
This is the single entry point for populating an instance. It stores the raw string and immediately calls InferType() to classify it. The inferred type is cached in m_type to avoid recomputing it in Type(). The typed accessors do not rely on m_type for their own conversions — they always read from m_raw directly — so a mismatch between m_type and the actual value never causes incorrect output from an accessor. m_type exists purely to give calling code a way to inspect the classification without calling a conversion method.
//+------------------------------------------------------------------+ //| Infer the logical type of a raw string value | //+------------------------------------------------------------------+ ENUM_PERSISTENT_TYPE CPersistentValue::InferType(const string raw) { //--- check for boolean literals if(raw == "true" || raw == "false") return(PERSISTENT_TYPE_BOOL); //--- check for datetime pattern YYYY.MM.DD HH:MM (length == 16) if(StringLen(raw) == 16) { if(raw[4] == '.' && raw[7] == '.' && raw[10] == ' ' && raw[13] == ':') return(PERSISTENT_TYPE_DATETIME); } //--- check for presence of decimal point indicating double if(StringFind(raw, ".") >= 0) { double parsed = StringToDouble(raw); string round_trip = DoubleToString(parsed, 5); if(StringFind(round_trip, ".") >= 0) return(PERSISTENT_TYPE_DOUBLE); } //--- check for pure integer via round-trip long parsed_long = StringToInteger(raw); string round_trip = IntegerToString(parsed_long); if(round_trip == raw) { if(parsed_long >= -2147483648 && parsed_long <= 2147483647) return(PERSISTENT_TYPE_INT); return(PERSISTENT_TYPE_LONG); } //--- default to string return(PERSISTENT_TYPE_STRING); }
InferType() applies detection rules in a strict priority order. Boolean is checked first because the strings "true" and "false" would otherwise reach the integer check and produce a valid round-trip result of zero — an incorrect classification. Datetime is checked second using a structural length-and-character test rather than a format parse, because MQL5 has no native regex support. Checking length before checking character positions avoids out-of-bounds indexing on short strings.
For doubles, the code first checks for a decimal point, then calls StringToDouble() and validates the result via a round-trip conversion. This rejects strings like "NaN" which StringToDouble() would convert without error. Integer detection performs the same round-trip strategy: convert to long, convert back to string, and compare with the original. A string like "42abc" converts to 42 but the round-trip produces "42", which does not match the original "42abc", so it falls through to PERSISTENT_TYPE_STRING correctly.
//+------------------------------------------------------------------+ //| Return the raw string value | //+------------------------------------------------------------------+ string CPersistentValue::AsString(void) const { return(m_raw); } //+------------------------------------------------------------------+ //| Return value interpreted as a 32-bit integer | //+------------------------------------------------------------------+ int CPersistentValue::AsInt(void) const { return((int)StringToInteger(m_raw)); } //+------------------------------------------------------------------+ //| Return value interpreted as a 64-bit integer | //+------------------------------------------------------------------+ long CPersistentValue::AsLong(void) const { return(StringToInteger(m_raw)); } //+------------------------------------------------------------------+ //| Return value interpreted as a double-precision float | //+------------------------------------------------------------------+ double CPersistentValue::AsDouble(void) const { return(StringToDouble(m_raw)); } //+------------------------------------------------------------------+ //| Return value interpreted as a boolean | //+------------------------------------------------------------------+ bool CPersistentValue::AsBool(void) const { return(m_raw == "true"); } //+------------------------------------------------------------------+ //| Return value interpreted as a datetime | //+------------------------------------------------------------------+ datetime CPersistentValue::AsDatetime(void) const { return(StringToTime(m_raw)); } //+------------------------------------------------------------------+ //| Return the inferred type of this value | //+------------------------------------------------------------------+ ENUM_PERSISTENT_TYPE CPersistentValue::Type(void) const { return(m_type); }
Each accessor performs its conversion directly from m_raw at call time. No intermediate conversion results are cached, keeping the memory footprint of an instance to one string plus one enum value. AsInt() casts the result of StringToInteger() — which always returns long — down to int. AsBool() performs a direct string comparison against "true" rather than treating any non-zero value as true, which preserves the strict boolean semantics defined by SerializeBool() in the serializer. AsDatetime() delegates to StringToTime(), which accepts the YYYY.MM.DD HH:MM format produced by SerializeDatetime().
PersistentParser.mqh
CPersistentParser handles all text processing required to turn a raw line from the flat file into a validated key-value pair. Every method in this class is static — parsing is a pure transformation from input string to output values, carrying no state between calls. Instantiating the class is never necessary.
//+------------------------------------------------------------------+ //| PersistentParser.mqh | //| Lightweight Persistent Key-Value Storage Engine | //+------------------------------------------------------------------+ #ifndef PERSISTENT_PARSER_MQH #define PERSISTENT_PARSER_MQH //+------------------------------------------------------------------+ //| CPersistentParser | //| Responsible for parsing individual lines of the flat file into | //| key-value pairs. Handles whitespace trimming, comment skipping, | //| empty-line detection, and delimiter validation. | //+------------------------------------------------------------------+ class CPersistentParser { private: static string TrimWhitespace(const string s); public: CPersistentParser(void); ~CPersistentParser(void); static bool ParseLine(const string line, string &out_key, string &out_value); static bool IsCommentOrEmpty(const string line); static bool IsValidKey(const string key); };
TrimWhitespace() is private because it is an implementation detail used internally by IsCommentOrEmpty() and ParseLine(). The two public validation helpers — IsCommentOrEmpty() and IsValidKey() — are exposed publicly so that calling code can perform pre-checks without going through ParseLine() if needed. The public entry point for normal use is ParseLine().
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CPersistentParser::CPersistentParser(void) { } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CPersistentParser::~CPersistentParser(void) { }
Both are empty. The class holds no member data and allocates no resources. They are defined explicitly to comply with the MetaQuotes coding convention that requires every class to declare its constructor and destructor, even when they have no body.
//+------------------------------------------------------------------+ //| Remove leading and trailing whitespace from a string | //+------------------------------------------------------------------+ string CPersistentParser::TrimWhitespace(const string s) { string result = s; int len = StringLen(result); //--- trim leading whitespace int start = 0; while(start < len && (result[start] == ' ' || result[start] == '\t')) start++; //--- trim trailing whitespace and carriage returns int end = len - 1; while(end >= start && (result[end] == ' ' || result[end] == '\t' || result[end] == '\r' || result[end] == '\n')) end--; if(start > end) return(""); return(StringSubstr(result, start, end - start + 1)); }
TrimWhitespace() is the foundation all other methods depend on. It advances start rightward while leading spaces or tabs are found, and retreats end leftward while trailing spaces, tabs, carriage returns, or newlines are found. The explicit handling of \r is essential: FileReadString() in FILE_TXT mode strips \n from line endings, but on files created on Windows the \r character preceding \n is left behind. A key read as "risk_mode\r" would fail equality comparisons against "risk_mode" silently and produce a lookup miss. When start exceeds end after both passes, the entire string consists of whitespace and an empty string is returned.
//+------------------------------------------------------------------+ //| Return true if line is empty or starts with '#' | //+------------------------------------------------------------------+ bool CPersistentParser::IsCommentOrEmpty(const string line) { string trimmed = TrimWhitespace(line); //--- skip empty lines if(StringLen(trimmed) == 0) return(true); //--- skip comment lines if(trimmed[0] == '#') return(true); return(false); }
This method acts as the first filter in the parsing pipeline. It trims the input before checking, so a line containing only spaces correctly reports as empty. A # character as the first non-whitespace character marks a comment line. Both conditions signal to ParseLine() that the line carries no data and should be skipped without attempting to split it.
//+------------------------------------------------------------------+ //| Return true if key contains no '=' character and is non-empty | //+------------------------------------------------------------------+ bool CPersistentParser::IsValidKey(const string key) { if(StringLen(key) == 0) return(false); if(StringFind(key, "=") >= 0) return(false); return(true); }
This method enforces the two structural rules for keys. A key must be non-empty, and it must not contain the = character. The = prohibition exists because the file format uses = as its only field delimiter. A key that itself contained = would make re-parsing ambiguous on the next file load — the parser splits at the first = it finds, so a key like a=b stored as a=b=value would be read back with key a and value b=value, silently corrupting the record.
//+------------------------------------------------------------------+ //| Parse a single line into key and value components | //+------------------------------------------------------------------+ bool CPersistentParser::ParseLine(const string line, string &out_key, string &out_value) { out_key = ""; out_value = ""; //--- skip comments and blank lines if(IsCommentOrEmpty(line)) return(false); //--- locate the first '=' delimiter int eq_pos = StringFind(line, "="); if(eq_pos < 1) return(false); // No delimiter or empty key //--- extract key as substring before '=' out_key = StringSubstr(line, 0, eq_pos); out_key = TrimWhitespace(out_key); //--- extract value as everything after '=' out_value = StringSubstr(line, eq_pos + 1); out_value = TrimWhitespace(out_value); //--- validate that the extracted key is well-formed if(!IsValidKey(out_key)) { out_key = ""; out_value = ""; return(false); } return(true); }
ParseLine() is the public entry point that combines all prior methods into one complete operation. Output parameters are cleared at the start so that a caller receiving false always finds empty strings rather than stale data from a prior call. The method locates the first = character using StringFind() and splits there. Using the first occurrence rather than the last means values may themselves contain = characters — for example, base64_key=abc=def== correctly produces key base64_key and value abc=def==. Trimming is applied to both the extracted key and value independently, handling any hand-editing of the file that may have introduced surrounding spaces.
PersistentSerializer.mqh
CPersistentSerializer is the mirror of CPersistentParser. Where the parser reads strings and produces typed values, the serializer takes typed values and produces strings suitable for writing to the flat file. Like the parser, all its methods are static and no instantiation is required.
//+------------------------------------------------------------------+ //| PersistentSerializer.mqh | //| Lightweight Persistent Key-Value Storage Engine | //+------------------------------------------------------------------+ #ifndef PERSISTENT_SERIALIZER_MQH #define PERSISTENT_SERIALIZER_MQH //+------------------------------------------------------------------+ //| CPersistentSerializer | //| Converts typed values to their canonical string representations | //| for storage in the flat file, and assembles complete file | //| content from parallel arrays of key-value string pairs. | //+------------------------------------------------------------------+ class CPersistentSerializer { public: CPersistentSerializer(void); ~CPersistentSerializer(void); static string SerializeInt(const int value); static string SerializeLong(const long value); static string SerializeDouble(const double value, const int digits = 8); static string SerializeBool(const bool value); static string SerializeDatetime(const datetime value); static string SerializeString(const string value); static string FormatLine(const string key, const string value); static bool WriteLines(const int handle, const string &keys[], const string &values[], const int count); };
The six Serialize*() methods correspond one-to-one with the six types in ENUM_PERSISTENT_TYPE. FormatLine() and WriteLines() handle the file-writing mechanics separately from the value-conversion logic, keeping each concern isolated.
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CPersistentSerializer::CPersistentSerializer(void) { } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CPersistentSerializer::~CPersistentSerializer(void) { }
Both are empty for the same reason as in CPersistentParser. The class is purely a collection of static functions and holds no instance state.
//+------------------------------------------------------------------+ //| Serialize an integer to its string representation | //+------------------------------------------------------------------+ string CPersistentSerializer::SerializeInt(const int value) { return(IntegerToString(value)); } //+------------------------------------------------------------------+ //| Serialize a long integer to its string representation | //+------------------------------------------------------------------+ string CPersistentSerializer::SerializeLong(const long value) { return(IntegerToString(value)); }
Both methods call IntegerToString(), which accepts long as its parameter — MQL5 promotes int to long automatically. They are defined as separate methods rather than a single overloaded function to match the explicit typed API of CPersistentStore, where SetInt() and SetLong() are distinct calls with distinct intent. Keeping them separate also means a caller never has to wonder whether passing an int to a long serializer will produce different output.
//+------------------------------------------------------------------+ //| Serialize a double to its string representation | //+------------------------------------------------------------------+ string CPersistentSerializer::SerializeDouble(const double value, const int digits = 8) { return(DoubleToString(value, digits)); }
The digits parameter defaults to 8, which is sufficient precision for most trading values while avoiding the excessive decimal places that DoubleToString() would produce with a higher setting. More importantly, this format prevents scientific notation: DoubleToString(0.0000001, 8) produces "0.00000010", which InferType() in CPersistentValue correctly classifies as a double on reload. Scientific notation such as "1e-7" would be classified as a string instead.
//+------------------------------------------------------------------+ //| Serialize a boolean to its string representation | //+------------------------------------------------------------------+ string CPersistentSerializer::SerializeBool(const bool value) { return(value ? "true" : "false"); }
The literal strings "true" and "false" are the only values that InferType() classifies as boolean. Using "1" and "0" instead would cause the value to be classified as an integer on reload, breaking the type round-trip. The lowercase convention also means that AsBool() — which compares directly against "true" — works without needing case-insensitive comparison.
//+------------------------------------------------------------------+ //| Serialize a datetime to its string representation | //+------------------------------------------------------------------+ string CPersistentSerializer::SerializeDatetime(const datetime value) { return(TimeToString(value, TIME_DATE | TIME_MINUTES)); }
TIME_DATE | TIME_MINUTES produces the format YYYY.MM.DD HH:MM, which is exactly 16 characters long. That length is the first thing InferType() checks when classifying a potential datetime. If TIME_SECONDS were added, the output would be 19 characters and InferType() would miss the datetime classification, falling through to string.
//+------------------------------------------------------------------+ //| Serialize a plain string — passthrough | //+------------------------------------------------------------------+ string CPersistentSerializer::SerializeString(const string value) { return(value); }
A plain string value requires no transformation. This method exists as a passthrough so that all six type paths in CPersistentStore follow the same call pattern — Set(key, CPersistentSerializer::Serialize*(value)) — without requiring a special case for strings.
//+------------------------------------------------------------------+ //| Format a single key=value line with a line ending | //+------------------------------------------------------------------+ string CPersistentSerializer::FormatLine(const string key, const string value) { return(key + "=" + value + "\n"); }
FormatLine() assembles one complete line of the flat file. The \n Unix newline terminator is used rather than \r\n so that the file is readable on both Windows and Unix systems without requiring conversion. TrimWhitespace() in CPersistentParser handles the case where a file with \r\n endings is opened — the trailing \r is stripped during parsing — so the file format is compatible regardless of which line ending convention is used.
//+------------------------------------------------------------------+ //| Write all key-value pairs to an open file handle | //+------------------------------------------------------------------+ bool CPersistentSerializer::WriteLines(const int handle, const string &keys[], const string &values[], const int count) { if(handle == INVALID_HANDLE) return(false); for(int i = 0; i < count; i++) { string line = FormatLine(keys[i], values[i]); uint bytes = FileWriteString(handle, line); if(bytes == 0) { PrintFormat("[PersistentSerializer] Write failed for key [%s]", keys[i]); return(false); } } return(true); }
WriteLines() receives a pair of parallel arrays and an explicit count rather than inferring the count from ArraySize(). This allows the caller to pass oversized arrays and still control exactly how many entries are written. The INVALID_HANDLE guard at the top ensures that a failed FileOpen() in the calling code does not silently produce a zero-byte write that returns success. FileWriteString() returns the number of bytes written; a return of zero indicates a write failure, which causes the method to log the offending key and return false immediately without attempting to write further entries.
PersistentCache.mqh
CPersistentCache wraps CHashMap<string,string> from the MQL5 Standard Library's Generic package and adds a maintained entry count. It is the in-memory layer of the system — fast O(1) access for all read and write operations, with no awareness of disk.
//+------------------------------------------------------------------+ //| PersistentCache.mqh | //| Lightweight Persistent Key-Value Storage Engine | //+------------------------------------------------------------------+ #ifndef PERSISTENT_CACHE_MQH #define PERSISTENT_CACHE_MQH //--- Includes #include <Generic\HashMap.mqh> //+------------------------------------------------------------------+ //| CPersistentCache | //| Wraps CHashMap<string,string> to provide an in-memory key-value | //| store with O(1) average-case Get, Set, Delete, and Exists | //| operations. All persistence is delegated to CPersistentStore. | //+------------------------------------------------------------------+ class CPersistentCache { private: CHashMap<string,string> m_map; // Backing hash map int m_count; // Number of entries currently held public: CPersistentCache(void); ~CPersistentCache(void); bool Set(const string key, const string value); bool Get(const string key, string &out_value); bool Delete(const string key); bool Exists(const string key); int Count(void) const; void Clear(void); bool GetAllPairs(string &out_keys[], string &out_values[]); };
m_map provides the hash-based storage. m_count is maintained separately because CHashMap does not expose a direct count accessor that matches the semantics needed here — tracking it manually avoids iterating the map just to know how many entries it holds.
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CPersistentCache::CPersistentCache(void) { m_count = 0; } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CPersistentCache::~CPersistentCache(void) { m_map.Clear(); }
The constructor initializes m_count to zero. The destructor calls m_map.Clear() explicitly to release any heap allocations inside CHashMap before the object is destroyed. Without this call, the map's internal bucket arrays would still be freed by the runtime, but the explicit clear communicates intent and ensures prompt release rather than relying on implicit destruction order.
//+------------------------------------------------------------------+ //| Insert or overwrite a key-value pair in the cache | //+------------------------------------------------------------------+ bool CPersistentCache::Set(const string key, const string value) { if(StringLen(key) == 0) return(false); //--- Probe for existing entry to distinguish insert from update string existing; bool already_present = m_map.TryGetValue(key, existing); if(!m_map.TrySetValue(key, value)) return(false); //--- Increment count only for new insertions if(!already_present) m_count++; return(true); }
Set() must distinguish between inserting a new key and updating an existing one, because only an insert should increment m_count. The probe via TryGetValue() before calling TrySetValue() is the mechanism for that distinction. Without this probe, repeated calls to Set() with the same key would increment m_count on every call, causing the count to drift above the true number of entries — which would then cause GetAllPairs() to allocate oversized arrays and potentially write garbage entries to the file.
//+------------------------------------------------------------------+ //| Retrieve a value by key; returns false if key is absent | //+------------------------------------------------------------------+ bool CPersistentCache::Get(const string key, string &out_value) { return(m_map.TryGetValue(key, out_value)); }
Get() delegates directly to TryGetValue(), which returns true and populates out_value if the key exists, or returns false and leaves out_value unchanged if the key is absent. The method has O(1) average-case complexity — the hash map computes the bucket index from the key string and performs a direct lookup without scanning.
//+------------------------------------------------------------------+ //| Remove a key-value pair from the cache | //+------------------------------------------------------------------+ bool CPersistentCache::Delete(const string key) { if(!m_map.Remove(key)) return(false); m_count--; return(true); }
Delete() calls Remove() on the map, which returns false if the key was not present. The method returns false in that case without modifying m_count. Only when Remove() succeeds — confirming that an entry actually existed and was removed — is m_count decremented. This keeps the count accurate regardless of whether the caller checks for key existence before calling Delete().
//+------------------------------------------------------------------+ //| Return true if the key exists in the cache | //+------------------------------------------------------------------+ bool CPersistentCache::Exists(const string key) { string dummy; return(m_map.TryGetValue(key, dummy)); }
Exists() reuses TryGetValue() with a throwaway output parameter rather than adding a separate ContainsKey() method to CHashMap. The dummy variable receives the value if the key exists, but it is never read. This is a deliberate design choice: it avoids an additional map traversal that a dedicated existence check would require, and it keeps the API surface of CPersistentCache minimal.
//+------------------------------------------------------------------+ //| Return the number of entries currently held in the cache | //+------------------------------------------------------------------+ int CPersistentCache::Count(void) const { return(m_count); } //+------------------------------------------------------------------+ //| Remove all entries from the cache | //+------------------------------------------------------------------+ void CPersistentCache::Clear(void) { m_map.Clear(); m_count = 0; }
Count() returns m_count directly without querying the map. Clear() calls m_map.Clear() to release all entries and resets m_count to zero. Both operations are O(1) with respect to the number of entries.
//+------------------------------------------------------------------+ //| Export all keys and values to parallel arrays for serialization | //+------------------------------------------------------------------+ bool CPersistentCache::GetAllPairs(string &out_keys[], string &out_values[]) { if(m_count == 0) { ArrayResize(out_keys, 0); ArrayResize(out_values, 0); return(false); } //--- Resize both arrays to the current entry count ArrayResize(out_keys, m_count); ArrayResize(out_values, m_count); //--- Copy all keys and values in a single call starting at index 0 m_map.CopyTo(out_keys, out_values, 0); return(true); }
GetAllPairs() is called by CPersistentStore::SaveFile() to obtain the complete cache contents for writing to disk. Both output arrays must be resized to m_count before calling CopyTo(), because CHashMap::CopyTo() writes into pre-allocated space rather than resizing its target arrays. The three-argument overload CopyTo(out_keys, out_values, 0) populates both arrays in a single pass, with the third argument specifying the starting index in the destination. The iteration order is hash-dependent and not guaranteed to be stable across runs, which is acceptable because the flat file format imposes no required key ordering.
PersistentStore.mqh
CPersistentStore is the module that EA code interacts with directly. It owns a CPersistentCache instance for in-memory access and a filename string for disk access, and it coordinates between them. The write-through policy is enforced here: every mutation goes to the cache first and then immediately triggers a full file rewrite.
//+------------------------------------------------------------------+ //| PersistentStore.mqh | //| Lightweight Persistent Key-Value Storage Engine | //+------------------------------------------------------------------+ #ifndef PERSISTENT_STORE_MQH #define PERSISTENT_STORE_MQH //--- Includes #include "PersistentValue.mqh" #include "PersistentParser.mqh" #include "PersistentSerializer.mqh" #include "PersistentCache.mqh" //+------------------------------------------------------------------+ //| CPersistentStore | //| Unified interface for persistent key-value storage. | //| Manages the lifecycle of the backing flat file and the in-memory | //| cache. Provides Get, Set, Delete, Exists, and typed accessors. | //+------------------------------------------------------------------+ class CPersistentStore { private: CPersistentCache m_cache; // In-memory hash map cache string m_file_name; // Backing flat file name bool m_initialized; // True after successful Init() bool LoadFile(void); bool SaveFile(void); public: CPersistentStore(void); ~CPersistentStore(void); bool Init(const string file_name); void Deinit(void); bool Set(const string key, const string value); bool SetInt(const string key, const int value); bool SetLong(const string key, const long value); bool SetDouble(const string key, const double value, const int digits = 8); bool SetBool(const string key, const bool value); bool SetDatetime(const string key, const datetime value); bool Get(const string key, string &out_value); bool GetInt(const string key, int &out_value); bool GetLong(const string key, long &out_value); bool GetDouble(const string key, double &out_value); bool GetBool(const string key, bool &out_value); bool GetDatetime(const string key, datetime &out_value); bool Delete(const string key); bool Exists(const string key); int Count(void) const; };
m_initialized is the central guard that prevents any operation from executing before Init() has succeeded. Every public method checks this flag before proceeding. LoadFile() and SaveFile() are private because no external code should trigger file operations directly — all disk interaction is managed internally as a consequence of the public API calls.
//+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CPersistentStore::CPersistentStore(void) { m_file_name = ""; m_initialized = false; } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CPersistentStore::~CPersistentStore(void) { }
The constructor establishes a safe uninitialized state. Any call to Set(), Get(), Delete(), or Exists() before Init() will find m_initialized as false and return immediately without accessing memory or disk. The destructor is empty — m_cache is destroyed by its own destructor, and m_file_name is released by the MQL5 string runtime.
//+------------------------------------------------------------------+ //| Initialize the store with a backing file name | //+------------------------------------------------------------------+ bool CPersistentStore::Init(const string file_name) { m_file_name = file_name; m_initialized = false; if(StringLen(m_file_name) == 0) { Print("[PersistentStore] Init failed: empty file name"); return(false); } //--- Load existing entries from disk if the file exists if(FileIsExist(m_file_name)) { if(!LoadFile()) { PrintFormat("[PersistentStore] Init failed: could not load [%s]", m_file_name); return(false); } } else { PrintFormat("[PersistentStore] New store: [%s] does not exist yet", m_file_name); } m_initialized = true; PrintFormat("[PersistentStore] Initialized — file: %s | Entries: %d", m_file_name, m_cache.Count()); return(true); }
Init() handles two scenarios through one code path. When the backing file exists, LoadFile() is called to populate the cache from disk. When the file is absent — a first-run condition — the cache is left empty and the file will be created on the first Set() call. m_initialized is set to true only after all checks pass. If LoadFile() fails, m_initialized remains false and all subsequent API calls will return false safely. The log output confirms both the filename and the number of entries that were restored, giving the developer immediate visibility into whether the previous session's state was recovered.
//+------------------------------------------------------------------+ //| Release resources and clear the cache | //+------------------------------------------------------------------+ void CPersistentStore::Deinit(void) { m_cache.Clear(); m_initialized = false; Print("[PersistentStore] Deinitialized"); }
Deinit() clears the in-memory cache and resets the initialization flag. It does not write to disk — the file is always current under the write-through policy, so no flush is needed at shutdown. The primary purpose of Deinit() is to release the CHashMap memory promptly and put the object back into a safe uninitialized state, so that if the EA is reattached to a chart without restarting the terminal, a subsequent Init() call starts from a clean baseline.
//+------------------------------------------------------------------+ //| Load key-value pairs from the flat file into the cache | //+------------------------------------------------------------------+ bool CPersistentStore::LoadFile(void) { m_cache.Clear(); //--- Open file for reading in text mode int handle = FileOpen(m_file_name, FILE_READ | FILE_TXT | FILE_ANSI); if(handle == INVALID_HANDLE) { PrintFormat("[PersistentStore] LoadFile: cannot open [%s] error=%d", m_file_name, GetLastError()); return(false); } int loaded = 0; //--- Read each line until end of file while(!FileIsEnding(handle)) { string line = FileReadString(handle); string key, value; if(!CPersistentParser::ParseLine(line, key, value)) continue; // Skip invalid lines silently m_cache.Set(key, value); loaded++; } FileClose(handle); PrintFormat("[PersistentStore] Loaded %d entries from [%s]", loaded, m_file_name); return(true); }
LoadFile() clears the cache before reading so that a second call — for example, after a chart reload — does not accumulate duplicate entries. The file is opened with FILE_READ | FILE_TXT | FILE_ANSI. The FILE_TXT flag enables line-by-line reading via FileReadString(), which returns one complete line per call. The FILE_ANSI flag ensures consistent single-byte character encoding. Lines that fail ParseLine() — blank lines, comments, or malformed entries — are silently skipped. This graceful degradation means a partially corrupted file loads as many valid entries as possible rather than failing entirely. The file handle is closed unconditionally after the loop, regardless of whether any errors occurred during parsing.
//+------------------------------------------------------------------+ //| Rewrite the flat file from the current cache contents | //+------------------------------------------------------------------+ bool CPersistentStore::SaveFile(void) { if(!m_initialized) return(false); //--- Retrieve all cache entries as parallel arrays string keys[]; string values[]; m_cache.GetAllPairs(keys, values); int count = m_cache.Count(); //--- Open file for writing, truncating any existing content int handle = FileOpen(m_file_name, FILE_WRITE | FILE_TXT | FILE_ANSI); if(handle == INVALID_HANDLE) { PrintFormat("[PersistentStore] SaveFile: cannot open [%s] error=%d", m_file_name, GetLastError()); return(false); } //--- Write all entries bool ok = CPersistentSerializer::WriteLines(handle, keys, values, count); FileClose(handle); if(ok) PrintFormat("[PersistentStore] Saved %d entries to [%s]", count, m_file_name); return(ok); }
SaveFile() is called by Set() and Delete() after every mutation. It exports the entire cache to parallel arrays via GetAllPairs(), opens the file with FILE_WRITE | FILE_TXT | FILE_ANSI — which truncates the existing file to zero bytes before writing — and delegates line formatting and writing to CPersistentSerializer::WriteLines(). The file handle is closed before SaveFile() returns, so no external tool reading the file immediately after a Set() call can observe a partially written state. The main failure mode is truncate-then-write. If the process stops after truncation but before completion, the next load may see an empty or partial file.
//+------------------------------------------------------------------+ //| Store a string value; update cache and rewrite file | //+------------------------------------------------------------------+ bool CPersistentStore::Set(const string key, const string value) { if(!m_initialized) return(false); if(!m_cache.Set(key, value)) return(false); PrintFormat("[PersistentStore] Set [%s] = %s", key, value); return(SaveFile()); } //+------------------------------------------------------------------+ //| Store an integer value | //+------------------------------------------------------------------+ bool CPersistentStore::SetInt(const string key, const int value) { return(Set(key, CPersistentSerializer::SerializeInt(value))); } //+------------------------------------------------------------------+ //| Store a long integer value | //+------------------------------------------------------------------+ bool CPersistentStore::SetLong(const string key, const long value) { return(Set(key, CPersistentSerializer::SerializeLong(value))); } //+------------------------------------------------------------------+ //| Store a double value | //+------------------------------------------------------------------+ bool CPersistentStore::SetDouble(const string key, const double value, const int digits = 8) { return(Set(key, CPersistentSerializer::SerializeDouble(value, digits))); } //+------------------------------------------------------------------+ //| Store a boolean value | //+------------------------------------------------------------------+ bool CPersistentStore::SetBool(const string key, const bool value) { return(Set(key, CPersistentSerializer::SerializeBool(value))); } //+------------------------------------------------------------------+ //| Store a datetime value | //+------------------------------------------------------------------+ bool CPersistentStore::SetDatetime(const string key, const datetime value) { return(Set(key, CPersistentSerializer::SerializeDatetime(value))); }
Set() is the core write method. It updates the cache first and only calls SaveFile() if the cache update succeeds. This ordering is intentional: if the cache update fails — for example, because the key is empty — no unnecessary disk write is attempted. The typed Set*() methods are thin wrappers that serialize their argument and delegate to Set(). They exist so that EA code can write ExtStore.SetInt("trade_counter", 42) rather than ExtStore.Set("trade_counter", IntegerToString(42)), keeping calling code readable and free from manual serialization.
//+------------------------------------------------------------------+ //| Retrieve a raw string value by key | //+------------------------------------------------------------------+ bool CPersistentStore::Get(const string key, string &out_value) { if(!m_initialized) return(false); bool found = m_cache.Get(key, out_value); if(found) PrintFormat("[PersistentStore] Get [%s] = %s", key, out_value); else PrintFormat("[PersistentStore] Get [%s] — key not found", key); return(found); } //+------------------------------------------------------------------+ //| Retrieve an integer value by key | //+------------------------------------------------------------------+ bool CPersistentStore::GetInt(const string key, int &out_value) { string raw; if(!Get(key, raw)) return(false); CPersistentValue pv; pv.FromString(raw); out_value = pv.AsInt(); return(true); } //+------------------------------------------------------------------+ //| Retrieve a long integer value by key | //+------------------------------------------------------------------+ bool CPersistentStore::GetLong(const string key, long &out_value) { string raw; if(!Get(key, raw)) return(false); CPersistentValue pv; pv.FromString(raw); out_value = pv.AsLong(); return(true); } //+------------------------------------------------------------------+ //| Retrieve a double value by key | //+------------------------------------------------------------------+ bool CPersistentStore::GetDouble(const string key, double &out_value) { string raw; if(!Get(key, raw)) return(false); CPersistentValue pv; pv.FromString(raw); out_value = pv.AsDouble(); return(true); } //+------------------------------------------------------------------+ //| Retrieve a boolean value by key | //+------------------------------------------------------------------+ bool CPersistentStore::GetBool(const string key, bool &out_value) { string raw; if(!Get(key, raw)) return(false); CPersistentValue pv; pv.FromString(raw); out_value = pv.AsBool(); return(true); } //+------------------------------------------------------------------+ //| Retrieve a datetime value by key | //+------------------------------------------------------------------+ bool CPersistentStore::GetDatetime(const string key, datetime &out_value) { string raw; if(!Get(key, raw)) return(false); CPersistentValue pv; pv.FromString(raw); out_value = pv.AsDatetime(); return(true); }
Get() retrieves the raw string from the cache and logs the result in both the found and not-found cases. The log on a miss is intentional — a silent miss during a debugging session is harder to diagnose than a visible key not found message. The typed Get*() methods follow a uniform pattern: retrieve the raw string, construct a CPersistentValue on the stack, call FromString() to populate and classify it, then call the appropriate typed accessor. The CPersistentValue instance is a local stack variable and is destroyed when the method returns, adding no persistent memory cost.
//+------------------------------------------------------------------+ //| Remove a key-value pair; update cache and rewrite file | //+------------------------------------------------------------------+ bool CPersistentStore::Delete(const string key) { if(!m_initialized) return(false); if(!m_cache.Delete(key)) { PrintFormat("[PersistentStore] Delete [%s] — key not found", key); return(false); } PrintFormat("[PersistentStore] Deleted [%s]", key); return(SaveFile()); }
Delete() removes the key from the cache and, if successful, triggers a full file rewrite via SaveFile(). If the key is not present in the cache, the method returns false without touching the file. The file rewrite after a deletion is unavoidable in the flat-file format — there is no mechanism to remove a single line from the middle of a sequential file without rewriting it entirely.
//+------------------------------------------------------------------+ //| Return true if the key exists in the store | //+------------------------------------------------------------------+ bool CPersistentStore::Exists(const string key) { if(!m_initialized) return(false); return(m_cache.Exists(key)); } //+------------------------------------------------------------------+ //| Return the number of entries currently in the store | //+------------------------------------------------------------------+ int CPersistentStore::Count(void) const { return(m_cache.Count()); }
Exists() delegates to the cache and performs no disk access. Count() returns the cached entry count directly. Both are O(1) operations. Exists() is the recommended way to guard a Get() call when the key may legitimately be absent — checking existence first avoids log noise from a not-found message in Get().
PersistentStoreDemo.mq5
//+------------------------------------------------------------------+ //| PersistentStoreDemo.mq5 | //| Lightweight Persistent Key-Value Storage Engine | //+------------------------------------------------------------------+ //--- Includes #include <Persistent_Key_Value_Store/PersistentStore.mqh> //--- Inputs input string InpFileName = "EAState.kv"; // Backing store file name input bool InpClearOnStart = false; // Delete file before loading //--- Global Variables CPersistentStore ExtStore; // Global persistent store instance
ExtStore is declared at global scope so that all EA event handlers — OnInit(), OnDeinit(), OnTick() — share the same store instance without passing it as a parameter. InpFileName allows the user to configure the backing file name from the EA's property dialog. InpClearOnStart provides a convenient reset mechanism for testing: setting it to true deletes the file before loading, simulating a clean first run without requiring the user to manually delete the file.
//+------------------------------------------------------------------+ //| Print a section separator to the Experts log | //+------------------------------------------------------------------+ void PrintSeparator(const string label) { PrintFormat("--- %s ---", label); }
A small utility function that prints a labeled separator line to the Experts tab. It makes the phased output of the demonstration visually scannable — each section of the log is clearly delimited, so the reader can identify which part of the demonstration produced each group of messages.
//+------------------------------------------------------------------+ //| Write initial EA state to the store | //+------------------------------------------------------------------+ void WriteInitialState(void) { PrintSeparator("Writing Initial State"); //--- Store a boolean flag indicating the daily target has been reached ExtStore.SetBool("daily_target_hit", true); //--- Store an integer trade counter ExtStore.SetInt("trade_counter", 42); //--- Store the timestamp of the last optimization run ExtStore.SetDatetime("last_optimization", StringToTime("2025.05.01 09:00")); //--- Store the current risk mode as a plain string ExtStore.Set("risk_mode", "conservative"); //--- Store a floating-point drawdown threshold ExtStore.SetDouble("max_drawdown_pct", 4.50, 2); //--- Store a long-form account identifier ExtStore.SetLong("account_id", 100234567890); }
This function writes six values covering all supported types: boolean, integer, datetime, string, double, and long. Each call exercises a different typed Set*() method. After WriteInitialState() returns, the flat file on disk contains all six entries and every subsequent run of the EA will find them available for restoration.
//+------------------------------------------------------------------+ //| Read back and verify all stored values | //+------------------------------------------------------------------+ void ReadAndVerifyState(void) { PrintSeparator("Reading Stored State"); //--- Retrieve and display boolean value bool daily_hit = false; if(ExtStore.GetBool("daily_target_hit", daily_hit)) PrintFormat("daily_target_hit = %s", daily_hit ? "true" : "false"); else Print("daily_target_hit — NOT FOUND"); //--- Retrieve and display integer counter int trade_cnt = 0; if(ExtStore.GetInt("trade_counter", trade_cnt)) PrintFormat("trade_counter = %d", trade_cnt); else Print("trade_counter — NOT FOUND"); //--- Retrieve and display datetime datetime last_opt = 0; if(ExtStore.GetDatetime("last_optimization", last_opt)) PrintFormat("last_optimization = %s", TimeToString(last_opt, TIME_DATE | TIME_MINUTES)); else Print("last_optimization — NOT FOUND"); //--- Retrieve and display string value string risk_mode = ""; if(ExtStore.Get("risk_mode", risk_mode)) PrintFormat("risk_mode = %s", risk_mode); else Print("risk_mode — NOT FOUND"); //--- Retrieve and display double value double dd_pct = 0.0; if(ExtStore.GetDouble("max_drawdown_pct", dd_pct)) PrintFormat("max_drawdown_pct = %.2f", dd_pct); else Print("max_drawdown_pct — NOT FOUND"); //--- Retrieve and display long integer long acct_id = 0; if(ExtStore.GetLong("account_id", acct_id)) PrintFormat("account_id = %I64d", acct_id); else Print("account_id — NOT FOUND"); }
ReadAndVerifyState() is called both after the initial write and after a simulated restart. On the first run it confirms that every Set*() call stored what was intended. On subsequent runs it confirms that the values survived the terminal restart and were correctly restored from the flat file. Each retrieval uses a local variable initialized to a known default so that a NOT FOUND result is unambiguous — the printed value would show the default rather than a previous value if the check were omitted.
//+------------------------------------------------------------------+ //| Simulate state changes that occur mid-session | //+------------------------------------------------------------------+ void SimulateMidSessionUpdate(void) { PrintSeparator("Simulating Mid-Session Update"); //--- Read current trade counter, increment, and write back int trade_cnt = 0; if(ExtStore.GetInt("trade_counter", trade_cnt)) { trade_cnt += 3; ExtStore.SetInt("trade_counter", trade_cnt); PrintFormat("trade_counter updated to %d", trade_cnt); } //--- Switch risk mode to aggressive ExtStore.Set("risk_mode", "aggressive"); Print("risk_mode switched to aggressive"); //--- Add a new key that did not exist in the initial state ExtStore.Set("session_id", "NY_20250501"); Print("session_id added"); }
This function demonstrates the read-modify-write pattern that is typical of real EA usage. The trade counter is read from the store, incremented in a local variable, and written back. Each Set*() call triggers a full file rewrite, but the file remains consistent throughout — if the terminal were closed after any single write, the file would contain all mutations up to that point. The addition of session_id demonstrates that new keys can be added at any time without any schema declaration.
//+------------------------------------------------------------------+ //| Demonstrate key existence check and deletion | //+------------------------------------------------------------------+ void DemonstrateDeletion(void) { PrintSeparator("Demonstrating Deletion"); //--- Verify key exists before deletion if(ExtStore.Exists("max_drawdown_pct")) Print("max_drawdown_pct EXISTS — proceeding to delete"); else Print("max_drawdown_pct NOT FOUND"); //--- Delete the key if(ExtStore.Delete("max_drawdown_pct")) Print("max_drawdown_pct deleted successfully"); else Print("max_drawdown_pct deletion failed"); //--- Confirm key is no longer present if(!ExtStore.Exists("max_drawdown_pct")) Print("Confirmed: max_drawdown_pct is no longer in store"); }
The function demonstrates the recommended pattern for deletion: check with Exists() first, then call Delete(), then verify with a second Exists() check. After this function returns, the flat file on disk no longer contains the max_drawdown_pct entry. On the next EA startup, LoadFile() will find no entry for that key.
//+------------------------------------------------------------------+ //| Print a summary of the final store state | //+------------------------------------------------------------------+ void PrintFinalState(void) { PrintSeparator("Final Store State"); PrintFormat("Total entries in store: %d", ExtStore.Count()); ReadAndVerifyState(); }
PrintFinalState() logs the entry count and re-reads all values to give the developer a complete picture of what is currently on disk. This is the state that will be restored on the next EA startup, making it straightforward to verify that the demonstration produced the expected persistent outcome.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit(void) { PrintSeparator("PersistentStoreDemo OnInit"); //--- Optionally delete the backing file to simulate a clean first run if(InpClearOnStart && FileIsExist(InpFileName)) { FileDelete(InpFileName); PrintFormat("Cleared existing file: %s", InpFileName); } //--- Initialize the persistent store if(!ExtStore.Init(InpFileName)) { Print("[Demo] Store initialization failed"); return(INIT_FAILED); } //--- If no existing state was found, write the initial values if(ExtStore.Count() == 0) { Print("[Demo] No existing state found — writing initial values"); WriteInitialState(); } else { PrintFormat("[Demo] Restored %d entries from previous session", ExtStore.Count()); } //--- Read and verify the current state ReadAndVerifyState(); //--- Simulate changes that would occur during a live session SimulateMidSessionUpdate(); //--- Demonstrate deletion DemonstrateDeletion(); //--- Print the final state as it will persist to the next restart PrintFinalState(); return(INIT_SUCCEEDED); }
OnInit() is the entry point that sequences all phases of the demonstration. The InpClearOnStart branch deletes the file before Init() is called, enabling a clean first-run simulation without manually deleting files. The Count() == 0 branch after initialization handles both first-run and clean-start cases — if no entries were loaded, the initial state is written. If entries were loaded, their count is reported instead, confirming that the previous session's state was restored. The remaining calls exercise each phase of the demonstration in a logical order.
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { PrintSeparator("PersistentStoreDemo OnDeinit"); PrintFormat("[Demo] Deinit reason: %d | Entries: %d", reason, ExtStore.Count()); ExtStore.Deinit(); } //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick(void) { //--- State management occurs in OnInit and OnDeinit //--- No tick-level logic is required for this demonstration }
OnDeinit() logs the deinitialization reason code and the current entry count, then calls ExtStore.Deinit() to release the cache. The reason code is useful during development — a reason of 0 indicates the EA was manually removed, while other codes indicate chart close, recompilation, or terminal shutdown. OnTick() is empty in this demonstration because all state management occurs in OnInit() and OnDeinit(). In a production EA, OnTick() would call Set*() or Get*() as needed during normal operation.

Human-readable .kv file opened in a text editor, showing daily_target_hit, trade_counter, last_optimization, and risk_mode entries.
Type Inference
The flat file stores all values as strings. When an EA retrieves a value, it must interpret that string as the correct type. The CPersistentStore API provides typed accessor variants that perform this conversion.
The type inference table:
| Type | Example stored string | Detection rule |
|---|---|---|
| bool | true or false | Case-sensitive match against "true" or "false" |
| int | 42 | StringToInteger() round-trip succeeds with no trailing content |
| long | 1234567890123 | Same as int — distinguished by magnitude |
| double | 1.23450 | StringToDouble() succeeds; presence of . is the distinguishing indicator |
| datetime | 2025.05.01 10:00 | Match against YYYY.MM.DD HH:MM pattern |
| string | conservative | Default — no other rule matched |
The store does not perform automatic type inference on load. The cache stores all values as strings, and conversion occurs when the caller invokes a typed Get() overload. This avoids ambiguity when a value such as "1" could be interpreted as an integer, a boolean, or a string depending on context.
Write-Through Cache
The cache is implemented using CHashMap<string,string>, which provides O(1) average-case insertion and lookup. All Get() and Exists() calls are served entirely from the cache after the initial file load. No disk I/O occurs during read operations.
The write-through policy dictates that every mutation — every Set() or Delete() — updates both the cache and the backing file atomically within the same call (but not atomically at the filesystem level). The file is always current. If the terminal closes unexpectedly between two Set() calls, the file contains all mutations up to the last completed Set() call.
The write-through architecture:
Set(key, value) ↓ Update CHashMap ↓ Rewrite flat file ↓ Disk synchronized
The alternative — write-behind caching, where mutations accumulate in memory and are flushed periodically — would improve write performance but introduces a window during which the disk state does not reflect the cache state. A terminal crash during that window causes data loss. For the typical EA state-management use case, the number of mutations per session is low enough that the write-through cost is irrelevant.
Startup Loading
On initialization, CPersistentStore reads the backing file sequentially from beginning to end, populating the hash map with every valid key-value pair. Invalid lines — empty lines, lines without a = delimiter, or lines beginning with # — are silently skipped. After the file is fully parsed, all subsequent read operations are served from the cache.
The startup loading sequence:
EA OnInit() ↓ CPersistentStore::Init(filename) ↓ FileOpen(filename, FILE_READ|FILE_TXT) ↓ Read each line ↓ Split at first '=' character ↓ Validate key and value ↓ CHashMap::Add(key, value) ↓ Cache ready — file handle closed
The file handle is closed immediately after loading completes. The store does not hold an open file handle during normal operation. Every SaveFile() call opens the file, writes its contents, and closes the file handle before returning. This avoids handle leaks and eliminates contention if external tools attempt to read the file.
Reliability Analysis
Missing files: If the backing file does not exist when Init() is called, the store initializes with an empty cache. The first Set() call creates the file. This is the correct behavior for a first-run scenario.
Corrupted files: Lines that cannot be parsed as key=value pairs are silently ignored. This provides graceful degradation: a partially corrupted file results in a partially populated cache. Known-good entries are retained; corrupted entries are dropped. If SaveFile() is called after loading a corrupted file, the corrupted entries are permanently removed because the rewrite is based on the cache contents.
Partial writes: SaveFile() opens the file with FILE_WRITE|FILE_TXT, which truncates the file before writing begins. If the terminal crashes after the file has been truncated but before writing completes, the file will be empty or incomplete on the next startup. This is the primary failure mode of the architecture. Mitigation strategies include writing to a temporary file and renaming it atomically — a technique that is not natively supported in MQL5's file API and requires a DLL. For most EA use cases, the probability of this failure and its consequences are acceptable.
Concurrent access: MQL5 does not provide file locking primitives. Two EA instances writing the same file simultaneously will corrupt it. The correct design is one CPersistentStore instance per file per terminal, with distinct filenames per EA.
Comparison With Alternatives
| Method | Advantages | Limitations |
|---|---|---|
| GlobalVariables | Built into terminal; no file I/O | Shared namespace; double only; not human-readable |
| Static variables | Zero overhead | Non-persistent; lost on any reload |
| In-memory arrays | Fast; flexible structure | Non-persistent |
| SQLite via DLL | Relational queries; ACID transactions | External dependency; deployment complexity |
| Flat key-value file | Human-readable; portable; no dependencies | Sequential updates; no locking; no schema |
No single approach is universally correct. GlobalVariables are appropriate when only numeric values need to persist and namespace collisions are not a concern. SQLite is appropriate when the state is relational or when ACID guarantees are required. Flat files are appropriate when simplicity, portability, and debuggability are the primary constraints.
Expected Log Output
After a first run and subsequent restart, the Experts tab will show:
[PersistentStore] Initialized — file: EAState.kv [PersistentStore] Loaded 0 entries (new file) [PersistentStore] Set [daily_target_hit] = true [PersistentStore] Set [trade_counter] = 42 [PersistentStore] Set [last_optimization] = 2025.05.01 09:00 [PersistentStore] Set [risk_mode] = conservative [PersistentStore] Saved 4 entries --- EA Restart --- [PersistentStore] Initialized — file: EAState.kv [PersistentStore] Loaded 4 entries [PersistentStore] Get [trade_counter] = 42 [PersistentStore] Get [daily_target_hit] = true [PersistentStore] Get [risk_mode] = conservative The file on disk during that session contains: daily_target_hit=true trade_counter=42 last_optimization=2025.05.01 09:00 risk_mode=conservative

Terminal Experts log demonstrating Set(), Get(), and successful value recovery after simulated EA restart.
Conclusion
The CPersistentStore architecture addresses the fundamental problem of EA state loss through a two-layer design: an in-memory CHashMap that provides O(1) read access, backed by a flat file that survives terminal restarts. The write-through cache policy eliminates the risk of cache-to-disk divergence at the cost of a full file rewrite on every mutation. For the typical EA state file — small, infrequently mutated, sequentially accessed — this cost is negligible.
The implementation avoids external dependencies entirely. It requires no DLL, no database engine, and no proprietary binary format. The backing file is human-readable, portable, and inspectable with any text editor. Type conversion is handled at retrieval time, keeping the storage layer simple and the parsing logic straightforward.
The primary limitations are the absence of file locking (limiting safe use to one writer per file), the O(n) disk cost of every write operation, and the partial-write failure mode that can occur if the terminal crashes mid-save. For production systems with strict reliability requirements, these limitations are real and should be weighed against the simplicity of the approach.
Within those constraints, a flat-file key-value store is a sound, practical, and easily auditable persistence mechanism for MetaTrader 5 Expert Advisors.
Rather than treating every terminal restart as a complete reset, the persistence layer presented in this article allows an Expert Advisor to resume execution with its operational context preserved. By separating state persistence from trading logic behind a simple typed interface, the resulting architecture remains lightweight, transparent, and easy to extend while improving the robustness of production trading systems.
Programs used in the article:
| # | Name | Type | Description |
|---|---|---|---|
| 1 | PersistentValue.mqh | Include File | Variant value representation with type detection helpers |
| 2 | PersistentParser.mqh | Include File | Line tokenization, key-value splitting, and validation |
| 3 | PersistentSerializer.mqh | Include File | Serialization of cache entries to flat-file text format |
| 4 | PersistentCache.mqh | Include File | CHashMap-backed in-memory cache layer |
| 5 | PersistentStore.mqh | Include File | Public store API — Get, Set, Delete, Exists, file sync |
| 6 | PersistentStoreDemo.mq5 | Demo EA | End-to-end demonstration with restart simulation and diagnostics |
| 7 | Persistent_Key_Value_Store.zip | Zip Archive | Zip archive containing all the attached files and their paths relative to the terminal's root folder. |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Engineering a Self-Healing Expert Advisor in MQL5 (Part 4): Trade-State Reconciliation and Safe Mode Recovery
Risk Manager for Trading Robots (Part I): Risk Control Include File for Expert Advisors
Feature Engineering for ML (Part 8): Entropy Features in MQL5
MQL5 Wizard Techniques you should know (Part 99): Using a KD-Tree and an Echo State Network in a Custom Money Management Class
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use