preview
Persistent Key-Value Store in MQL5: Using Flat Files as a Lightweight Database for EA State

Persistent Key-Value Store in MQL5: Using Flat Files as a Lightweight Database for EA State

MetaTrader 5Trading systems |
93 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

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

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

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.


Engineering a Self-Healing Expert Advisor in MQL5 (Part 4): Trade-State Reconciliation and Safe Mode Recovery Engineering a Self-Healing Expert Advisor in MQL5 (Part 4): Trade-State Reconciliation and Safe Mode Recovery
This article adds trade-state reconciliation and Safe Mode recovery to a MetaTrader 5 Expert Advisor. The EA continuously validates recovery integrity by comparing the live broker position with the persisted SQLite state and the in-memory runtime state. Detected inconsistencies trigger an automatic transition to Safe Mode, suspending virtual protection, breakeven, and trailing management until the recovery state can be trusted again.
Risk Manager for Trading Robots (Part I): Risk Control Include File for Expert Advisors Risk Manager for Trading Robots (Part I): Risk Control Include File for Expert Advisors
Trading is characterized by high demands on risk management discipline. The article presents an analysis of the main reasons for traders' failures and proposes a technical solution in the form of the CEnhancedRiskManager class for the MQL5 platform. It includes practical testing on an aggressive grid EA.
Feature Engineering for ML (Part 8): Entropy Features in MQL5 Feature Engineering for ML (Part 8): Entropy Features in MQL5
An MQL5 port of four entropy estimators — Shannon, Plug-In, Lempel-Ziv, and Kontoyiannis — operating on the intrabar tick-rule sequence. CopyTicksRange() limits data to the broker's cached tick window, so features apply to recent bars only. The implementation encodes bid-direction ticks from MqlTick, replaces NumPy-dependent steps with array-based methods, and ships CEntropyFeatures.mqh and EntropyViewer.mq5 for EA and indicator use.
MQL5 Wizard Techniques you should know (Part 99): Using a KD-Tree and an Echo State Network in a Custom Money Management Class MQL5 Wizard Techniques you should know (Part 99): Using a KD-Tree and an Echo State Network in a Custom Money Management Class
This article lays out 'CMoneyKDTreeESN' custom money management class usable with the MQL5 Wizard, that combines the KD-Tree algorithm and the Echo State Network. We use the KD-Tree on log returns and ATR to give us a risk score, while the ESN tracks recent flow to give us a bounded lot size multiplier. Our class is usable in a variety of Wizard assembled Expert Advisors as shown here with the Envelopes and RSI signals, with a broad objective of modulating exposure in high-volatility and tail-risk environments.